@safekb/mcp 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/safekb-mcp.js +604 -110
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -371,11 +371,21 @@ custom fields and the key name of every custom field (sealed ones too) stay read
|
|
|
371
371
|
space takes its credentials along (a credential's group in the old space is left behind).
|
|
372
372
|
- `reveal_sealed_credential` and `get_sealed_totp` decrypt locally. The hosted `reveal_credential` and
|
|
373
373
|
`get_credential_totp` cannot open a sealed credential.
|
|
374
|
-
- AI access
|
|
375
|
-
`
|
|
376
|
-
`
|
|
377
|
-
|
|
378
|
-
|
|
374
|
+
- AI access: `visible`, `use_only` or `human_only`, per credential (`llm_visibility`) and per sealed
|
|
375
|
+
field (`llm_field_visibility`, e.g. a card's number and CVC hidden, its expiry date visible).
|
|
376
|
+
`reveal_sealed_credential` and `get_sealed_totp` work only on a `visible` field. New and migrated
|
|
377
|
+
sealed credentials start with the tenant default (Settings → Sealed credentials; `use_only` until
|
|
378
|
+
set). Only you make anything more visible, in the SafeKB app; `set_sealed_credential_llm_visibility`
|
|
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.
|
|
379
389
|
- Visible fields can be edited without the secret. Such an edit is flagged (`visible_tampered`) with the
|
|
380
390
|
changed fields until a signed save accepts it (`accept_visible_changes`).
|
|
381
391
|
|
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.12.1",
|
|
2585
2585
|
description: "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
|
|
2586
2586
|
license: "SEE LICENSE IN LICENSE",
|
|
2587
2587
|
author: "Mustafa Öztürk",
|
|
@@ -3276,9 +3276,10 @@ class SealedApi {
|
|
|
3276
3276
|
fetchSealedCredential(id) {
|
|
3277
3277
|
return this.http.getJson(`/credentials/${id}/sealed`);
|
|
3278
3278
|
}
|
|
3279
|
-
setLlmVisibility(id, llmVisibility) {
|
|
3279
|
+
setLlmVisibility(id, llmVisibility, field) {
|
|
3280
3280
|
return this.http.putJson(`/credentials/${id}/sealed/llm-visibility`, {
|
|
3281
|
-
llm_visibility: llmVisibility
|
|
3281
|
+
llm_visibility: llmVisibility,
|
|
3282
|
+
...field === undefined ? {} : { field }
|
|
3282
3283
|
});
|
|
3283
3284
|
}
|
|
3284
3285
|
lastAttestedVisibleFields(id) {
|
|
@@ -8914,6 +8915,282 @@ class LocalKeys {
|
|
|
8914
8915
|
}
|
|
8915
8916
|
}
|
|
8916
8917
|
|
|
8918
|
+
// src/sealed/credentialAccess.ts
|
|
8919
|
+
var REVEALABLE = new Set(["visible"]);
|
|
8920
|
+
var USABLE = new Set(["visible", "use_only"]);
|
|
8921
|
+
var CUSTOM_PREFIX2 = customFieldName("");
|
|
8922
|
+
function visibleOf(credential2) {
|
|
8923
|
+
return {
|
|
8924
|
+
title: credential2.title,
|
|
8925
|
+
username: credential2.username,
|
|
8926
|
+
url: credential2.url,
|
|
8927
|
+
tags: credential2.tags,
|
|
8928
|
+
notes: credential2.notes,
|
|
8929
|
+
custom: credential2.custom.flatMap(({ key, value }) => value === undefined ? [] : [{ key, value }])
|
|
8930
|
+
};
|
|
8931
|
+
}
|
|
8932
|
+
function sealedValue(open5, field) {
|
|
8933
|
+
const { values, credential: credential2 } = open5;
|
|
8934
|
+
const value = field.startsWith(CUSTOM_PREFIX2) ? values.custom.find((item) => item.key === field.slice(CUSTOM_PREFIX2.length))?.value : SEALED_CREDENTIAL_FIELDS.includes(field) ? values[field] : undefined;
|
|
8935
|
+
if (value === undefined || value === null) {
|
|
8936
|
+
throw new ClientError(`Credential ${credential2.id} has no sealed field '${field}'. Sealed fields: password, totp, sealed_notes, custom:{key}.`);
|
|
8937
|
+
}
|
|
8938
|
+
return value;
|
|
8939
|
+
}
|
|
8940
|
+
function assertDistinctCustomKeys(visible, values) {
|
|
8941
|
+
const keys = [...visible.custom, ...values.custom].map(({ key }) => key);
|
|
8942
|
+
if (keys.some((key) => key.length === 0)) {
|
|
8943
|
+
throw new ClientError("A custom field needs a key");
|
|
8944
|
+
}
|
|
8945
|
+
if (new Set(keys).size !== keys.length) {
|
|
8946
|
+
throw new ClientError("Custom field keys must be unique");
|
|
8947
|
+
}
|
|
8948
|
+
}
|
|
8949
|
+
|
|
8950
|
+
class CredentialAccess {
|
|
8951
|
+
ctx;
|
|
8952
|
+
constructor(ctx) {
|
|
8953
|
+
this.ctx = ctx;
|
|
8954
|
+
}
|
|
8955
|
+
context(credentialId) {
|
|
8956
|
+
return { tenant: this.ctx.tenant, credential: credentialId };
|
|
8957
|
+
}
|
|
8958
|
+
async newDek(rootId, credentialId) {
|
|
8959
|
+
const { keys } = await this.ctx.session.open(rootId);
|
|
8960
|
+
const dek = generateDek();
|
|
8961
|
+
return {
|
|
8962
|
+
dek,
|
|
8963
|
+
encDek: serializeEnvelope(await wrapDek(keys, credentialId, dek))
|
|
8964
|
+
};
|
|
8965
|
+
}
|
|
8966
|
+
async rewrapDek(dek, rootId, credentialId) {
|
|
8967
|
+
const { keys } = await this.ctx.session.open(rootId);
|
|
8968
|
+
return serializeEnvelope(await wrapDek(keys, credentialId, dek));
|
|
8969
|
+
}
|
|
8970
|
+
async open(credentialId) {
|
|
8971
|
+
return this.decrypt(await this.ctx.api.fetchSealedCredential(credentialId));
|
|
8972
|
+
}
|
|
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.");
|
|
8975
|
+
}
|
|
8976
|
+
async openForUse(credentialId, field) {
|
|
8977
|
+
return this.openAllowing(credentialId, field, USABLE, () => "no tool may use it: ask the user to enter it.");
|
|
8978
|
+
}
|
|
8979
|
+
async openAllowing(credentialId, field, allowed, refusal) {
|
|
8980
|
+
const credential2 = await this.ctx.api.fetchSealedCredential(credentialId);
|
|
8981
|
+
const visibility = Object.hasOwn(credential2.llm_field_visibility, field) ? credential2.llm_field_visibility[field] : credential2.llm_visibility;
|
|
8982
|
+
if (!allowed.has(visibility)) {
|
|
8983
|
+
throw new ClientError(`Field '${field}' of credential ${credentialId} (${credential2.title}) is ${visibility}: ${refusal(visibility)} Only the user can change this, in the SafeKB app.`);
|
|
8984
|
+
}
|
|
8985
|
+
return this.decrypt(credential2);
|
|
8986
|
+
}
|
|
8987
|
+
async decrypt(credential2) {
|
|
8988
|
+
const credentialId = credential2.id;
|
|
8989
|
+
const { keys } = await this.ctx.session.open(credential2.seal_root_id);
|
|
8990
|
+
const dek = await unwrapDek(keys, credentialId, parseEnvelope(credential2.enc_dek));
|
|
8991
|
+
const values = await unsealCredentialFields(dek, this.context(credentialId), credential2.enc_fields);
|
|
8992
|
+
return { credential: credential2, dek, values };
|
|
8993
|
+
}
|
|
8994
|
+
async seal(credentialId, dek, visible, values) {
|
|
8995
|
+
assertDistinctCustomKeys(visible, values);
|
|
8996
|
+
return {
|
|
8997
|
+
visible,
|
|
8998
|
+
encFields: await sealCredentialFields(dek, this.context(credentialId), values),
|
|
8999
|
+
visibleDigest: await visibleFieldsDigest(visible)
|
|
9000
|
+
};
|
|
9001
|
+
}
|
|
9002
|
+
async sign(message, signerRootIds) {
|
|
9003
|
+
const signers = [];
|
|
9004
|
+
for (const rootId of signerRootIds) {
|
|
9005
|
+
signers.push((await this.ctx.session.open(rootId)).keys);
|
|
9006
|
+
}
|
|
9007
|
+
return signMessage(await buildCredentialMessage(message), signers);
|
|
9008
|
+
}
|
|
9009
|
+
}
|
|
9010
|
+
|
|
9011
|
+
// src/sinks/redaction.ts
|
|
9012
|
+
var MAX_SHOWN_CHARS2 = 32 * 1024;
|
|
9013
|
+
var escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9014
|
+
var bothCases = (hex) => hex.toUpperCase() === hex.toLowerCase() ? hex : `(?:${hex.toUpperCase()}|${hex.toLowerCase()})`;
|
|
9015
|
+
function characterPattern(char) {
|
|
9016
|
+
const percent = [...Buffer.from(char, "utf8")].map((byte) => `%${bothCases(byte.toString(16).padStart(2, "0"))}`).join("");
|
|
9017
|
+
const unicode = [...char].map((unit) => `\\\\u${bothCases(unit.charCodeAt(0).toString(16).padStart(4, "0"))}`).join("");
|
|
9018
|
+
const forms = [escapeRegex(char), percent, unicode];
|
|
9019
|
+
if (char === " ")
|
|
9020
|
+
forms.push("\\+", "%s");
|
|
9021
|
+
if (char === "'")
|
|
9022
|
+
forms.push(escapeRegex(`'\\''`));
|
|
9023
|
+
if (char === '"' || char === "\\" || char === "/") {
|
|
9024
|
+
forms.push(escapeRegex(`\\${char}`));
|
|
9025
|
+
}
|
|
9026
|
+
return `(?:${forms.join("|")})`;
|
|
9027
|
+
}
|
|
9028
|
+
function wholeForms(value) {
|
|
9029
|
+
const bytes = Buffer.from(value, "utf8");
|
|
9030
|
+
const base64 = bytes.toString("base64");
|
|
9031
|
+
const hex = bytes.toString("hex");
|
|
9032
|
+
return [
|
|
9033
|
+
base64,
|
|
9034
|
+
base64.replace(/=+$/, ""),
|
|
9035
|
+
bytes.toString("base64url"),
|
|
9036
|
+
hex,
|
|
9037
|
+
hex.toUpperCase()
|
|
9038
|
+
];
|
|
9039
|
+
}
|
|
9040
|
+
|
|
9041
|
+
class Redactor {
|
|
9042
|
+
pattern;
|
|
9043
|
+
markers;
|
|
9044
|
+
longest;
|
|
9045
|
+
constructor(secrets) {
|
|
9046
|
+
const kept = secrets.filter(({ value }) => value.length > 0).sort((a, b) => b.value.length - a.value.length);
|
|
9047
|
+
this.markers = kept.map(({ name }) => `«redacted:${name}»`);
|
|
9048
|
+
this.pattern = kept.length === 0 ? null : new RegExp(kept.map(({ value }) => `(${[
|
|
9049
|
+
[...value].map(characterPattern).join(""),
|
|
9050
|
+
...wholeForms(value).map(escapeRegex)
|
|
9051
|
+
].join("|")})`).join("|"), "g");
|
|
9052
|
+
this.longest = Math.max(0, ...kept.map(({ value }) => Buffer.byteLength(value, "utf8") * 6));
|
|
9053
|
+
}
|
|
9054
|
+
redact(text) {
|
|
9055
|
+
if (this.pattern === null)
|
|
9056
|
+
return text;
|
|
9057
|
+
return text.replace(this.pattern, (...match) => {
|
|
9058
|
+
const groups = match.slice(1, this.markers.length + 1);
|
|
9059
|
+
return this.markers[groups.findIndex((g) => g !== undefined)];
|
|
9060
|
+
});
|
|
9061
|
+
}
|
|
9062
|
+
shown(text, cut) {
|
|
9063
|
+
const whole = cut ? text.slice(0, Math.max(0, text.length - this.longest)) : text;
|
|
9064
|
+
const scrubbed = this.redact(whole);
|
|
9065
|
+
return {
|
|
9066
|
+
text: scrubbed.slice(0, MAX_SHOWN_CHARS2),
|
|
9067
|
+
truncated: cut || scrubbed.length > MAX_SHOWN_CHARS2
|
|
9068
|
+
};
|
|
9069
|
+
}
|
|
9070
|
+
}
|
|
9071
|
+
|
|
9072
|
+
// src/sinks/secretHandles.ts
|
|
9073
|
+
var HANDLE = /\{\{secret:\/\/([0-9a-f]{24})\/([^{}|]+)(?:\|([^{}|]*))?\}\}/g;
|
|
9074
|
+
var ANY_HANDLE = /secret:\/\//;
|
|
9075
|
+
var ENCODINGS = {
|
|
9076
|
+
raw: (value) => value,
|
|
9077
|
+
url: encodeURIComponent,
|
|
9078
|
+
form: (value) => new URLSearchParams({ v: value }).toString().slice(2),
|
|
9079
|
+
json: (value) => JSON.stringify(value).slice(1, -1)
|
|
9080
|
+
};
|
|
9081
|
+
function encodingOf(name, fallback) {
|
|
9082
|
+
if (name === undefined)
|
|
9083
|
+
return fallback;
|
|
9084
|
+
if (!Object.hasOwn(ENCODINGS, name)) {
|
|
9085
|
+
throw new ClientError(`Unknown handle encoding '|${name}': use |url, |form, |json or |raw`);
|
|
9086
|
+
}
|
|
9087
|
+
return name;
|
|
9088
|
+
}
|
|
9089
|
+
var handleKey = ({ credentialId, field }) => `${credentialId}/${field}`;
|
|
9090
|
+
function refsIn(text) {
|
|
9091
|
+
const refs = [];
|
|
9092
|
+
const rest = text.replace(HANDLE, (_, credentialId, field, encoding2) => {
|
|
9093
|
+
encodingOf(encoding2, "raw");
|
|
9094
|
+
refs.push({ credentialId, field });
|
|
9095
|
+
return "";
|
|
9096
|
+
});
|
|
9097
|
+
if (ANY_HANDLE.test(rest)) {
|
|
9098
|
+
throw new ClientError("A secret handle must be written {{secret://<24-character credential id>/<field>}}, optionally with |url, |form or |json, e.g. {{secret://0123456789abcdef01234567/password|form}}");
|
|
9099
|
+
}
|
|
9100
|
+
return refs;
|
|
9101
|
+
}
|
|
9102
|
+
function hasHandle(text) {
|
|
9103
|
+
return ANY_HANDLE.test(text);
|
|
9104
|
+
}
|
|
9105
|
+
function fillHandles(text, lookup, fallback = "raw") {
|
|
9106
|
+
return text.replace(HANDLE, (_, credentialId, field, encoding2) => ENCODINGS[encodingOf(encoding2, fallback)](lookup({ credentialId, field })));
|
|
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
|
+
|
|
9117
|
+
// src/sinks/secretResolver.ts
|
|
9118
|
+
var TOTP_FIELD = "totp";
|
|
9119
|
+
var MIN_TOTP_SECONDS = 5;
|
|
9120
|
+
var SYSTEM_CLOCK = {
|
|
9121
|
+
now: () => Date.now(),
|
|
9122
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9123
|
+
};
|
|
9124
|
+
|
|
9125
|
+
class ResolvedSecrets {
|
|
9126
|
+
byKey;
|
|
9127
|
+
redactor;
|
|
9128
|
+
constructor(resolved) {
|
|
9129
|
+
this.byKey = new Map(resolved.map((item) => [handleKey(item.ref), item]));
|
|
9130
|
+
this.redactor = new Redactor(resolved.map(({ ref, value }) => ({ name: handleKey(ref), value })));
|
|
9131
|
+
}
|
|
9132
|
+
fill(text, fallback = "raw") {
|
|
9133
|
+
return fillHandles(text, (ref) => this.valueOf(ref), fallback);
|
|
9134
|
+
}
|
|
9135
|
+
valueOf(ref) {
|
|
9136
|
+
const resolved = this.byKey.get(handleKey(ref));
|
|
9137
|
+
if (resolved === undefined) {
|
|
9138
|
+
throw new Error(`Handle ${handleKey(ref)} was not resolved`);
|
|
9139
|
+
}
|
|
9140
|
+
return resolved.value;
|
|
9141
|
+
}
|
|
9142
|
+
redact(text) {
|
|
9143
|
+
return this.redactor.redact(text);
|
|
9144
|
+
}
|
|
9145
|
+
shown(text, cut) {
|
|
9146
|
+
return this.redactor.shown(text, cut);
|
|
9147
|
+
}
|
|
9148
|
+
used() {
|
|
9149
|
+
return [...this.byKey.values()].map(({ ref, title }) => ({
|
|
9150
|
+
credential_id: ref.credentialId,
|
|
9151
|
+
title,
|
|
9152
|
+
field: ref.field
|
|
9153
|
+
}));
|
|
9154
|
+
}
|
|
9155
|
+
}
|
|
9156
|
+
|
|
9157
|
+
class SecretResolver {
|
|
9158
|
+
access;
|
|
9159
|
+
clock;
|
|
9160
|
+
constructor(access, clock = SYSTEM_CLOCK) {
|
|
9161
|
+
this.access = access;
|
|
9162
|
+
this.clock = clock;
|
|
9163
|
+
}
|
|
9164
|
+
async resolve(texts) {
|
|
9165
|
+
const refs = new Map;
|
|
9166
|
+
for (const ref of texts.flatMap(refsIn))
|
|
9167
|
+
refs.set(handleKey(ref), ref);
|
|
9168
|
+
const ordered = [...refs.values()].sort((a, b) => Number(a.field === TOTP_FIELD) - Number(b.field === TOTP_FIELD));
|
|
9169
|
+
const resolved = [];
|
|
9170
|
+
for (const ref of ordered)
|
|
9171
|
+
resolved.push(await this.resolveOne(ref));
|
|
9172
|
+
return new ResolvedSecrets(resolved);
|
|
9173
|
+
}
|
|
9174
|
+
async resolveOne(ref) {
|
|
9175
|
+
const open5 = await this.access.openForUse(ref.credentialId, ref.field);
|
|
9176
|
+
const value = sealedValue(open5, ref.field);
|
|
9177
|
+
return {
|
|
9178
|
+
ref,
|
|
9179
|
+
title: open5.credential.title,
|
|
9180
|
+
value: ref.field === TOTP_FIELD ? await this.freshCode(value) : value
|
|
9181
|
+
};
|
|
9182
|
+
}
|
|
9183
|
+
async freshCode(seed) {
|
|
9184
|
+
const params = parseTotp(seed);
|
|
9185
|
+
for (;; ) {
|
|
9186
|
+
const current = await totp(params, this.clock.now());
|
|
9187
|
+
if (current.secondsRemaining > MIN_TOTP_SECONDS)
|
|
9188
|
+
return current.code;
|
|
9189
|
+
await this.clock.sleep(current.secondsRemaining * 1000);
|
|
9190
|
+
}
|
|
9191
|
+
}
|
|
9192
|
+
}
|
|
9193
|
+
|
|
8917
9194
|
// src/sealed/content.ts
|
|
8918
9195
|
function metaOf(content) {
|
|
8919
9196
|
return {
|
|
@@ -9252,81 +9529,6 @@ class CreateService {
|
|
|
9252
9529
|
}
|
|
9253
9530
|
}
|
|
9254
9531
|
|
|
9255
|
-
// src/sealed/credentialAccess.ts
|
|
9256
|
-
function visibleOf(credential2) {
|
|
9257
|
-
return {
|
|
9258
|
-
title: credential2.title,
|
|
9259
|
-
username: credential2.username,
|
|
9260
|
-
url: credential2.url,
|
|
9261
|
-
tags: credential2.tags,
|
|
9262
|
-
notes: credential2.notes,
|
|
9263
|
-
custom: credential2.custom.flatMap(({ key, value }) => value === undefined ? [] : [{ key, value }])
|
|
9264
|
-
};
|
|
9265
|
-
}
|
|
9266
|
-
function assertDistinctCustomKeys(visible, values) {
|
|
9267
|
-
const keys = [...visible.custom, ...values.custom].map(({ key }) => key);
|
|
9268
|
-
if (keys.some((key) => key.length === 0)) {
|
|
9269
|
-
throw new ClientError("A custom field needs a key");
|
|
9270
|
-
}
|
|
9271
|
-
if (new Set(keys).size !== keys.length) {
|
|
9272
|
-
throw new ClientError("Custom field keys must be unique");
|
|
9273
|
-
}
|
|
9274
|
-
}
|
|
9275
|
-
|
|
9276
|
-
class CredentialAccess {
|
|
9277
|
-
ctx;
|
|
9278
|
-
constructor(ctx) {
|
|
9279
|
-
this.ctx = ctx;
|
|
9280
|
-
}
|
|
9281
|
-
context(credentialId) {
|
|
9282
|
-
return { tenant: this.ctx.tenant, credential: credentialId };
|
|
9283
|
-
}
|
|
9284
|
-
async newDek(rootId, credentialId) {
|
|
9285
|
-
const { keys } = await this.ctx.session.open(rootId);
|
|
9286
|
-
const dek = generateDek();
|
|
9287
|
-
return {
|
|
9288
|
-
dek,
|
|
9289
|
-
encDek: serializeEnvelope(await wrapDek(keys, credentialId, dek))
|
|
9290
|
-
};
|
|
9291
|
-
}
|
|
9292
|
-
async rewrapDek(dek, rootId, credentialId) {
|
|
9293
|
-
const { keys } = await this.ctx.session.open(rootId);
|
|
9294
|
-
return serializeEnvelope(await wrapDek(keys, credentialId, dek));
|
|
9295
|
-
}
|
|
9296
|
-
async open(credentialId) {
|
|
9297
|
-
return this.decrypt(await this.ctx.api.fetchSealedCredential(credentialId));
|
|
9298
|
-
}
|
|
9299
|
-
async openForReveal(credentialId) {
|
|
9300
|
-
const credential2 = await this.ctx.api.fetchSealedCredential(credentialId);
|
|
9301
|
-
if (credential2.llm_visibility !== "visible") {
|
|
9302
|
-
throw new ClientError(`Credential ${credentialId} (${credential2.title}) is ${credential2.llm_visibility}: its secrets are never shown to an LLM. No tool uses a secret without showing it yet: ask the user to enter it. Only the user can change this, in the SafeKB app.`);
|
|
9303
|
-
}
|
|
9304
|
-
return this.decrypt(credential2);
|
|
9305
|
-
}
|
|
9306
|
-
async decrypt(credential2) {
|
|
9307
|
-
const credentialId = credential2.id;
|
|
9308
|
-
const { keys } = await this.ctx.session.open(credential2.seal_root_id);
|
|
9309
|
-
const dek = await unwrapDek(keys, credentialId, parseEnvelope(credential2.enc_dek));
|
|
9310
|
-
const values = await unsealCredentialFields(dek, this.context(credentialId), credential2.enc_fields);
|
|
9311
|
-
return { credential: credential2, dek, values };
|
|
9312
|
-
}
|
|
9313
|
-
async seal(credentialId, dek, visible, values) {
|
|
9314
|
-
assertDistinctCustomKeys(visible, values);
|
|
9315
|
-
return {
|
|
9316
|
-
visible,
|
|
9317
|
-
encFields: await sealCredentialFields(dek, this.context(credentialId), values),
|
|
9318
|
-
visibleDigest: await visibleFieldsDigest(visible)
|
|
9319
|
-
};
|
|
9320
|
-
}
|
|
9321
|
-
async sign(message, signerRootIds) {
|
|
9322
|
-
const signers = [];
|
|
9323
|
-
for (const rootId of signerRootIds) {
|
|
9324
|
-
signers.push((await this.ctx.session.open(rootId)).keys);
|
|
9325
|
-
}
|
|
9326
|
-
return signMessage(await buildCredentialMessage(message), signers);
|
|
9327
|
-
}
|
|
9328
|
-
}
|
|
9329
|
-
|
|
9330
9532
|
// src/sealed/secretScan.ts
|
|
9331
9533
|
var PEM_BLOCK = /-----BEGIN [A-Z0-9 ]+-----/;
|
|
9332
9534
|
var SECRET_WORD = "pass(?:word|wd|phrase|code|wort)?|kennwort|geheim(?:nis)?|pwd|pin|puk|tan|secret|token|api[ _-]?key|access[ _-]?key|private[ _-]?key|client[ _-]?secret|bearer|recovery[ _-]?codes?|backup[ _-]?codes?|seed|cvv|cvc";
|
|
@@ -9703,7 +9905,6 @@ class CredentialMigration {
|
|
|
9703
9905
|
|
|
9704
9906
|
// src/sealed/credentialService.ts
|
|
9705
9907
|
var NOT_FOUND2 = 404;
|
|
9706
|
-
var CUSTOM_PREFIX2 = customFieldName("");
|
|
9707
9908
|
var VISIBLE_NAMES = [
|
|
9708
9909
|
"title",
|
|
9709
9910
|
"username",
|
|
@@ -9751,8 +9952,8 @@ class CredentialService {
|
|
|
9751
9952
|
this.access = access;
|
|
9752
9953
|
}
|
|
9753
9954
|
async reveal(credentialId, field) {
|
|
9754
|
-
const open5 = await this.access.openForReveal(credentialId);
|
|
9755
|
-
const value =
|
|
9955
|
+
const open5 = await this.access.openForReveal(credentialId, field);
|
|
9956
|
+
const value = sealedValue(open5, field);
|
|
9756
9957
|
return {
|
|
9757
9958
|
id: credentialId,
|
|
9758
9959
|
version: open5.credential.version,
|
|
@@ -9762,8 +9963,8 @@ class CredentialService {
|
|
|
9762
9963
|
};
|
|
9763
9964
|
}
|
|
9764
9965
|
async totp(credentialId, nowMs = Date.now()) {
|
|
9765
|
-
const open5 = await this.access.openForReveal(credentialId);
|
|
9766
|
-
const code = await totp(parseTotp(
|
|
9966
|
+
const open5 = await this.access.openForReveal(credentialId, "totp");
|
|
9967
|
+
const code = await totp(parseTotp(sealedValue(open5, "totp")), nowMs);
|
|
9767
9968
|
return {
|
|
9768
9969
|
id: credentialId,
|
|
9769
9970
|
code: code.code,
|
|
@@ -9771,17 +9972,13 @@ class CredentialService {
|
|
|
9771
9972
|
...await this.attestation(open5.credential)
|
|
9772
9973
|
};
|
|
9773
9974
|
}
|
|
9774
|
-
async setLlmVisibility(credentialId, llmVisibility) {
|
|
9775
|
-
const credential2 = await this.ctx.api.setLlmVisibility(credentialId, llmVisibility);
|
|
9776
|
-
return {
|
|
9777
|
-
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
if (value === undefined || value === null) {
|
|
9782
|
-
throw new ClientError(`Credential ${credential2.id} has no sealed field '${field}'. Sealed fields: password, totp, sealed_notes, custom:{key}.`);
|
|
9783
|
-
}
|
|
9784
|
-
return value;
|
|
9975
|
+
async setLlmVisibility(credentialId, llmVisibility, field) {
|
|
9976
|
+
const credential2 = await this.ctx.api.setLlmVisibility(credentialId, llmVisibility, field);
|
|
9977
|
+
return {
|
|
9978
|
+
id: credential2.id,
|
|
9979
|
+
llm_visibility: credential2.llm_visibility,
|
|
9980
|
+
llm_field_visibility: credential2.llm_field_visibility
|
|
9981
|
+
};
|
|
9785
9982
|
}
|
|
9786
9983
|
async attestation(credential2) {
|
|
9787
9984
|
const current = visibleOf(credential2);
|
|
@@ -10739,7 +10936,8 @@ function createSealedServices(ctx) {
|
|
|
10739
10936
|
attachments: new AttachmentService(ctx, access),
|
|
10740
10937
|
deposits: new DepositService(ctx, access, tree, documents),
|
|
10741
10938
|
credentials: new CredentialService(ctx, credentialAccess),
|
|
10742
|
-
migration: new CredentialMigration(ctx, credentialAccess)
|
|
10939
|
+
migration: new CredentialMigration(ctx, credentialAccess),
|
|
10940
|
+
secrets: new SecretResolver(credentialAccess)
|
|
10743
10941
|
};
|
|
10744
10942
|
}
|
|
10745
10943
|
|
|
@@ -12769,7 +12967,7 @@ __export(exports_util, {
|
|
|
12769
12967
|
floatSafeRemainder: () => floatSafeRemainder,
|
|
12770
12968
|
finalizeIssue: () => finalizeIssue,
|
|
12771
12969
|
extend: () => extend,
|
|
12772
|
-
escapeRegex: () =>
|
|
12970
|
+
escapeRegex: () => escapeRegex2,
|
|
12773
12971
|
esc: () => esc,
|
|
12774
12972
|
defineLazy: () => defineLazy,
|
|
12775
12973
|
createTransparentProxy: () => createTransparentProxy,
|
|
@@ -13021,7 +13219,7 @@ var getParsedType = (data) => {
|
|
|
13021
13219
|
};
|
|
13022
13220
|
var propertyKeyTypes = new Set(["string", "number", "symbol"]);
|
|
13023
13221
|
var primitiveTypes = new Set(["string", "number", "bigint", "boolean", "symbol", "undefined"]);
|
|
13024
|
-
function
|
|
13222
|
+
function escapeRegex2(str) {
|
|
13025
13223
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13026
13224
|
}
|
|
13027
13225
|
function clone(inst, def, params) {
|
|
@@ -13717,7 +13915,7 @@ function emoji() {
|
|
|
13717
13915
|
var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
|
|
13718
13916
|
var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
|
|
13719
13917
|
var mac = (delimiter) => {
|
|
13720
|
-
const escapedDelim =
|
|
13918
|
+
const escapedDelim = escapeRegex2(delimiter ?? ":");
|
|
13721
13919
|
return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
|
|
13722
13920
|
};
|
|
13723
13921
|
var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
|
@@ -14217,7 +14415,7 @@ var $ZodCheckUpperCase = /* @__PURE__ */ $constructor("$ZodCheckUpperCase", (ins
|
|
|
14217
14415
|
});
|
|
14218
14416
|
var $ZodCheckIncludes = /* @__PURE__ */ $constructor("$ZodCheckIncludes", (inst, def) => {
|
|
14219
14417
|
$ZodCheck.init(inst, def);
|
|
14220
|
-
const escapedRegex =
|
|
14418
|
+
const escapedRegex = escapeRegex2(def.includes);
|
|
14221
14419
|
const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
|
|
14222
14420
|
def.pattern = pattern;
|
|
14223
14421
|
inst._zod.onattach.push((inst2) => {
|
|
@@ -14241,7 +14439,7 @@ var $ZodCheckIncludes = /* @__PURE__ */ $constructor("$ZodCheckIncludes", (inst,
|
|
|
14241
14439
|
});
|
|
14242
14440
|
var $ZodCheckStartsWith = /* @__PURE__ */ $constructor("$ZodCheckStartsWith", (inst, def) => {
|
|
14243
14441
|
$ZodCheck.init(inst, def);
|
|
14244
|
-
const pattern = new RegExp(`^${
|
|
14442
|
+
const pattern = new RegExp(`^${escapeRegex2(def.prefix)}.*`);
|
|
14245
14443
|
def.pattern ?? (def.pattern = pattern);
|
|
14246
14444
|
inst._zod.onattach.push((inst2) => {
|
|
14247
14445
|
const bag = inst2._zod.bag;
|
|
@@ -14264,7 +14462,7 @@ var $ZodCheckStartsWith = /* @__PURE__ */ $constructor("$ZodCheckStartsWith", (i
|
|
|
14264
14462
|
});
|
|
14265
14463
|
var $ZodCheckEndsWith = /* @__PURE__ */ $constructor("$ZodCheckEndsWith", (inst, def) => {
|
|
14266
14464
|
$ZodCheck.init(inst, def);
|
|
14267
|
-
const pattern = new RegExp(`.*${
|
|
14465
|
+
const pattern = new RegExp(`.*${escapeRegex2(def.suffix)}$`);
|
|
14268
14466
|
def.pattern ?? (def.pattern = pattern);
|
|
14269
14467
|
inst._zod.onattach.push((inst2) => {
|
|
14270
14468
|
const bag = inst2._zod.bag;
|
|
@@ -15803,7 +16001,7 @@ var $ZodEnum = /* @__PURE__ */ $constructor("$ZodEnum", (inst, def) => {
|
|
|
15803
16001
|
const values = getEnumValues(def.entries);
|
|
15804
16002
|
const valuesSet = new Set(values);
|
|
15805
16003
|
inst._zod.values = valuesSet;
|
|
15806
|
-
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ?
|
|
16004
|
+
inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex2(o) : o.toString()).join("|")})$`);
|
|
15807
16005
|
inst._zod.parse = (payload, _ctx) => {
|
|
15808
16006
|
const input = payload.value;
|
|
15809
16007
|
if (valuesSet.has(input)) {
|
|
@@ -15825,7 +16023,7 @@ var $ZodLiteral = /* @__PURE__ */ $constructor("$ZodLiteral", (inst, def) => {
|
|
|
15825
16023
|
}
|
|
15826
16024
|
const values = new Set(def.values);
|
|
15827
16025
|
inst._zod.values = values;
|
|
15828
|
-
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ?
|
|
16026
|
+
inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex2(o) : o ? escapeRegex2(o.toString()) : String(o)).join("|")})$`);
|
|
15829
16027
|
inst._zod.parse = (payload, _ctx) => {
|
|
15830
16028
|
const input = payload.value;
|
|
15831
16029
|
if (values.has(input)) {
|
|
@@ -16181,7 +16379,7 @@ var $ZodTemplateLiteral = /* @__PURE__ */ $constructor("$ZodTemplateLiteral", (i
|
|
|
16181
16379
|
const end = source.endsWith("$") ? source.length - 1 : source.length;
|
|
16182
16380
|
regexParts.push(source.slice(start, end));
|
|
16183
16381
|
} else if (part === null || primitiveTypes.has(typeof part)) {
|
|
16184
|
-
regexParts.push(
|
|
16382
|
+
regexParts.push(escapeRegex2(`${part}`));
|
|
16185
16383
|
} else {
|
|
16186
16384
|
throw new Error(`Invalid template literal part: ${part}`);
|
|
16187
16385
|
}
|
|
@@ -35723,6 +35921,271 @@ function keyTools(parts) {
|
|
|
35723
35921
|
];
|
|
35724
35922
|
}
|
|
35725
35923
|
|
|
35924
|
+
// src/sinks/processRunner.ts
|
|
35925
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
35926
|
+
var MAX_CAPTURE_BYTES = 1024 * 1024;
|
|
35927
|
+
var KILL_GRACE_MS = 2000;
|
|
35928
|
+
|
|
35929
|
+
class Capture {
|
|
35930
|
+
chunks = [];
|
|
35931
|
+
size = 0;
|
|
35932
|
+
cut = false;
|
|
35933
|
+
add(chunk) {
|
|
35934
|
+
const room = MAX_CAPTURE_BYTES - this.size;
|
|
35935
|
+
if (chunk.length > room)
|
|
35936
|
+
this.cut = true;
|
|
35937
|
+
if (room <= 0)
|
|
35938
|
+
return;
|
|
35939
|
+
const kept = chunk.subarray(0, room);
|
|
35940
|
+
this.chunks.push(kept);
|
|
35941
|
+
this.size += kept.length;
|
|
35942
|
+
}
|
|
35943
|
+
text() {
|
|
35944
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
35945
|
+
}
|
|
35946
|
+
}
|
|
35947
|
+
async function runProcess(request) {
|
|
35948
|
+
const [command, ...args] = request.argv;
|
|
35949
|
+
if (command === undefined) {
|
|
35950
|
+
throw new ClientError("argv needs at least the program to run");
|
|
35951
|
+
}
|
|
35952
|
+
return new Promise((resolve, reject) => {
|
|
35953
|
+
const child = spawn3(command, args, {
|
|
35954
|
+
cwd: request.cwd,
|
|
35955
|
+
env: { ...process.env, ...request.env },
|
|
35956
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
35957
|
+
detached: true
|
|
35958
|
+
});
|
|
35959
|
+
const killGroup = (signal) => {
|
|
35960
|
+
if (child.pid === undefined)
|
|
35961
|
+
return;
|
|
35962
|
+
try {
|
|
35963
|
+
process.kill(-child.pid, signal);
|
|
35964
|
+
} catch (error48) {
|
|
35965
|
+
if (error48.code !== "ESRCH")
|
|
35966
|
+
throw error48;
|
|
35967
|
+
}
|
|
35968
|
+
};
|
|
35969
|
+
const stdout = new Capture;
|
|
35970
|
+
const stderr = new Capture;
|
|
35971
|
+
let timedOut = false;
|
|
35972
|
+
let stdinError = null;
|
|
35973
|
+
const timer = setTimeout(() => {
|
|
35974
|
+
timedOut = true;
|
|
35975
|
+
killGroup("SIGTERM");
|
|
35976
|
+
setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref();
|
|
35977
|
+
}, request.timeoutMs);
|
|
35978
|
+
child.stdout.on("data", (chunk) => stdout.add(chunk));
|
|
35979
|
+
child.stderr.on("data", (chunk) => stderr.add(chunk));
|
|
35980
|
+
child.stdin.on("error", (error48) => {
|
|
35981
|
+
if (error48.code !== "EPIPE")
|
|
35982
|
+
stdinError = error48;
|
|
35983
|
+
});
|
|
35984
|
+
child.once("error", (error48) => {
|
|
35985
|
+
clearTimeout(timer);
|
|
35986
|
+
reject(new ClientError(error48.code === "ENOENT" ? `${command} was not found${request.cwd ? ` (cwd ${request.cwd})` : ""}` : `${command} could not be started: ${error48.code ?? "unknown error"}`));
|
|
35987
|
+
});
|
|
35988
|
+
child.once("close", (exitCode, signal) => {
|
|
35989
|
+
clearTimeout(timer);
|
|
35990
|
+
if (stdinError !== null) {
|
|
35991
|
+
reject(new ClientError(`stdin of ${command} could not be written: ${stdinError.code}`));
|
|
35992
|
+
return;
|
|
35993
|
+
}
|
|
35994
|
+
resolve({
|
|
35995
|
+
exitCode,
|
|
35996
|
+
signal,
|
|
35997
|
+
timedOut,
|
|
35998
|
+
stdout: stdout.text(),
|
|
35999
|
+
stderr: stderr.text(),
|
|
36000
|
+
stdoutCut: stdout.cut,
|
|
36001
|
+
stderrCut: stderr.cut
|
|
36002
|
+
});
|
|
36003
|
+
});
|
|
36004
|
+
child.stdin.end(request.stdin ?? "");
|
|
36005
|
+
});
|
|
36006
|
+
}
|
|
36007
|
+
|
|
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
|
+
// src/sinks/execSink.ts
|
|
36076
|
+
class ExecSink {
|
|
36077
|
+
resolver;
|
|
36078
|
+
constructor(resolver) {
|
|
36079
|
+
this.resolver = resolver;
|
|
36080
|
+
}
|
|
36081
|
+
async run(args) {
|
|
36082
|
+
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.");
|
|
36084
|
+
}
|
|
36085
|
+
const env = args.env ?? {};
|
|
36086
|
+
const secrets = await this.resolver.resolve([
|
|
36087
|
+
...Object.values(env),
|
|
36088
|
+
args.stdin ?? ""
|
|
36089
|
+
]);
|
|
36090
|
+
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 };
|
|
36126
|
+
}
|
|
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) {
|
|
36171
|
+
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)
|
|
36181
|
+
});
|
|
36182
|
+
return { response, ...await cappedText(response) };
|
|
36183
|
+
} catch (error48) {
|
|
36184
|
+
throw new ClientError(`The request failed: ${secrets.redact(causeOf(error48))}`);
|
|
36185
|
+
}
|
|
36186
|
+
}
|
|
36187
|
+
}
|
|
36188
|
+
|
|
35726
36189
|
// src/tools/attachmentTools.ts
|
|
35727
36190
|
function attachmentTools(attachments) {
|
|
35728
36191
|
return [
|
|
@@ -35774,11 +36237,12 @@ function credentialTools(credentials, migration, names) {
|
|
|
35774
36237
|
field: exports_external.string().min(1).describe("password, totp, sealed_notes, or custom:{key}.")
|
|
35775
36238
|
}, (args) => credentials.reveal(args.credential_id, args.field)),
|
|
35776
36239
|
defineTool("get_sealed_totp", `Compute the current TOTP code of a sealed credential on this machine, without revealing the seed. Refused unless its llm_visibility is visible. ${EXPOSURE_NOTE}`, { credential_id: credentialId }, (args) => credentials.totp(args.credential_id)),
|
|
35777
|
-
defineTool("set_sealed_credential_llm_visibility", "Make a sealed credential's secrets less visible to LLMs: use_only (never revealed to an LLM) or human_only (never revealed, entered by a person). It only tightens: human_only cannot go back to use_only from here, and only the user makes
|
|
36240
|
+
defineTool("set_sealed_credential_llm_visibility", "Make a sealed credential's secrets, or one sealed field, less visible to LLMs: use_only (never revealed to an LLM) or human_only (never revealed, entered by a person). It only tightens: human_only cannot go back to use_only from here, and only the user makes anything visible, in the SafeKB app. The value is not signed and does not change the credential's version.", {
|
|
35778
36241
|
credential_id: credentialId,
|
|
35779
|
-
llm_visibility: exports_external.enum(["use_only", "human_only"]).describe("human_only is the more restrictive.")
|
|
35780
|
-
|
|
35781
|
-
|
|
36242
|
+
llm_visibility: exports_external.enum(["use_only", "human_only"]).describe("human_only is the more restrictive."),
|
|
36243
|
+
field: exports_external.string().min(1).describe("One sealed field: password, totp, sealed_notes or custom:{key}. Omit for the whole credential; a field without its own value follows the credential.").optional()
|
|
36244
|
+
}, (args) => credentials.setLlmVisibility(args.credential_id, args.llm_visibility, args.field)),
|
|
36245
|
+
defineTool("insert_sealed_credential", `Create a sealed credential in a seal root. It is kept in the space of that root. Whether an LLM may reveal its secrets follows the default the user chose for the tenant; only the user changes it, in the SafeKB app. password, totp, sealed_notes and the values of sealed custom fields are encrypted on this machine; title, username, url, tags, notes, visible custom fields and the key name of every custom field (sealed ones too) stay readable by the server so search and duplicate detection work. ${SECRET_ARGUMENT_NOTE}`, {
|
|
35782
36246
|
root_id: rootRef("The seal root that will own it."),
|
|
35783
36247
|
title: exports_external.string().min(1).describe("Visible to the server."),
|
|
35784
36248
|
folder_id: exports_external.string().min(1).describe("Group it belongs to.").optional(),
|
|
@@ -35916,6 +36380,34 @@ function documentTools(creator, documents, search, names) {
|
|
|
35916
36380
|
];
|
|
35917
36381
|
}
|
|
35918
36382
|
|
|
36383
|
+
// 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) {
|
|
36388
|
+
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}`, {
|
|
36390
|
+
argv: exports_external.array(exports_external.string()).min(1).describe('Program and arguments, e.g. ["uv", "run", "script.py"].'),
|
|
36391
|
+
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(),
|
|
36393
|
+
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))
|
|
36408
|
+
];
|
|
36409
|
+
}
|
|
36410
|
+
|
|
35919
36411
|
// src/tools/treeTools.ts
|
|
35920
36412
|
var documentId = objectId2("Id of the sealed document or folder.");
|
|
35921
36413
|
function treeTools(tree, names) {
|
|
@@ -35969,7 +36461,8 @@ The keys of this machine, and backups, are handled by tools that never take or r
|
|
|
35969
36461
|
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.
|
|
35970
36462
|
|
|
35971
36463
|
- Reveal a secret only when the task needs it, and prefer get_sealed_totp over revealing a TOTP seed.
|
|
35972
|
-
- llm_visibility
|
|
36464
|
+
- 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.
|
|
35973
36466
|
- 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.`;
|
|
35974
36467
|
function createSealedTools(ctx) {
|
|
35975
36468
|
const services = createSealedServices(ctx);
|
|
@@ -35980,7 +36473,8 @@ function createSealedTools(ctx) {
|
|
|
35980
36473
|
...treeTools(services.tree, names),
|
|
35981
36474
|
...attachmentTools(services.attachments),
|
|
35982
36475
|
...depositTools(services.deposits, names),
|
|
35983
|
-
...credentialTools(services.credentials, services.migration, names)
|
|
36476
|
+
...credentialTools(services.credentials, services.migration, names),
|
|
36477
|
+
...sinkTools(new ExecSink(services.secrets), new HttpSink(services.secrets), new AdbSink(services.secrets))
|
|
35984
36478
|
];
|
|
35985
36479
|
}
|
|
35986
36480
|
|
package/package.json
CHANGED