@safekb/mcp 0.12.0 → 0.13.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 +9 -7
- package/dist/safekb-mcp.js +116 -218
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -377,13 +377,15 @@ 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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
handle becomes the current code
|
|
385
|
-
|
|
386
|
-
|
|
380
|
+
- Using a secret without showing it: SafeKB only provides the value. `secret_exec` runs your own
|
|
381
|
+
program with it in env, stdin or a temp file (`files`, mode 0600, deleted afterwards), never argv;
|
|
382
|
+
every integration (HTTP API, browser form, `adb shell input text`, ssh, wallet, `cryptsetup`, …) is
|
|
383
|
+
that program. The agent names a secret by handle, `{{secret://<credential_id>/<field>}}`; a `totp`
|
|
384
|
+
handle becomes the current code, and `|form`, `|json` or `|url` encodes the value for where the
|
|
385
|
+
program puts it unchanged. `visible` and `use_only` fields can be used; `human_only` fields never.
|
|
386
|
+
Output comes back scrubbed of the value, in any mix of percent-, form- and JSON-encoding and as
|
|
387
|
+
base64 or hex, with the credentials and fields used. Scrubbing stops careless leaks, not a program
|
|
388
|
+
written to leak one.
|
|
387
389
|
- Visible fields can be edited without the secret. Such an edit is flagged (`visible_tampered`) with the
|
|
388
390
|
changed fields until a signed save accepts it (`accept_visible_changes`).
|
|
389
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.13.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.");
|
|
@@ -9010,17 +9010,26 @@ class CredentialAccess {
|
|
|
9010
9010
|
|
|
9011
9011
|
// src/sinks/redaction.ts
|
|
9012
9012
|
var MAX_SHOWN_CHARS2 = 32 * 1024;
|
|
9013
|
-
|
|
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) {
|
|
9014
9029
|
const bytes = Buffer.from(value, "utf8");
|
|
9015
9030
|
const base64 = bytes.toString("base64");
|
|
9016
9031
|
const hex = bytes.toString("hex");
|
|
9017
9032
|
return [
|
|
9018
|
-
value,
|
|
9019
|
-
encodeURIComponent(value),
|
|
9020
|
-
new URLSearchParams({ v: value }).toString().slice(2),
|
|
9021
|
-
value.replaceAll(" ", "%s"),
|
|
9022
|
-
value.replaceAll(" ", "%s").replaceAll("'", `'\\''`),
|
|
9023
|
-
JSON.stringify(value).slice(1, -1),
|
|
9024
9033
|
base64,
|
|
9025
9034
|
base64.replace(/=+$/, ""),
|
|
9026
9035
|
bytes.toString("base64url"),
|
|
@@ -9028,28 +9037,27 @@ function encodedForms(value) {
|
|
|
9028
9037
|
hex.toUpperCase()
|
|
9029
9038
|
];
|
|
9030
9039
|
}
|
|
9031
|
-
var escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9032
9040
|
|
|
9033
9041
|
class Redactor {
|
|
9034
|
-
markers;
|
|
9035
9042
|
pattern;
|
|
9043
|
+
markers;
|
|
9036
9044
|
longest;
|
|
9037
9045
|
constructor(secrets) {
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
}
|
|
9045
|
-
const forms = [...this.markers.keys()].sort((a, b) => b.length - a.length);
|
|
9046
|
-
this.pattern = forms.length === 0 ? null : new RegExp(forms.map(escapeRegex).join("|"), "g");
|
|
9047
|
-
this.longest = forms[0]?.length ?? 0;
|
|
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));
|
|
9048
9053
|
}
|
|
9049
9054
|
redact(text) {
|
|
9050
9055
|
if (this.pattern === null)
|
|
9051
9056
|
return text;
|
|
9052
|
-
return text.replace(this.pattern, (
|
|
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
|
+
});
|
|
9053
9061
|
}
|
|
9054
9062
|
shown(text, cut) {
|
|
9055
9063
|
const whole = cut ? text.slice(0, Math.max(0, text.length - this.longest)) : text;
|
|
@@ -9062,33 +9070,40 @@ class Redactor {
|
|
|
9062
9070
|
}
|
|
9063
9071
|
|
|
9064
9072
|
// src/sinks/secretHandles.ts
|
|
9065
|
-
var HANDLE = /\{\{secret:\/\/([0-9a-f]{24})\/([^{}]+)
|
|
9073
|
+
var HANDLE = /\{\{secret:\/\/([0-9a-f]{24})\/([^{}|]+)(?:\|([^{}|]*))?\}\}/g;
|
|
9066
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
|
+
}
|
|
9067
9089
|
var handleKey = ({ credentialId, field }) => `${credentialId}/${field}`;
|
|
9068
9090
|
function refsIn(text) {
|
|
9069
9091
|
const refs = [];
|
|
9070
|
-
const rest = text.replace(HANDLE, (_, credentialId, field) => {
|
|
9092
|
+
const rest = text.replace(HANDLE, (_, credentialId, field, encoding2) => {
|
|
9093
|
+
encodingOf(encoding2, "raw");
|
|
9071
9094
|
refs.push({ credentialId, field });
|
|
9072
9095
|
return "";
|
|
9073
9096
|
});
|
|
9074
9097
|
if (ANY_HANDLE.test(rest)) {
|
|
9075
|
-
throw new ClientError("A secret handle must be written {{secret://<24-character credential id>/<field>}}, e.g. {{secret://0123456789abcdef01234567/password}}");
|
|
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}}");
|
|
9076
9099
|
}
|
|
9077
9100
|
return refs;
|
|
9078
9101
|
}
|
|
9079
9102
|
function hasHandle(text) {
|
|
9080
9103
|
return ANY_HANDLE.test(text);
|
|
9081
9104
|
}
|
|
9082
|
-
function fillHandles(text, lookup) {
|
|
9083
|
-
return text.replace(HANDLE, (_, credentialId, field) => lookup({ credentialId, field }));
|
|
9084
|
-
}
|
|
9085
|
-
function requireHandlesAfterOrigin(url) {
|
|
9086
|
-
const first = url.search(ANY_HANDLE);
|
|
9087
|
-
if (first === -1)
|
|
9088
|
-
return;
|
|
9089
|
-
if (!/^https?:\/\/[^/?#{}]+[/?#]/.test(url.slice(0, first))) {
|
|
9090
|
-
throw new ClientError("A secret handle can go in the path, query or fragment of a url, never in its scheme, host or port.");
|
|
9091
|
-
}
|
|
9105
|
+
function fillHandles(text, lookup, fallback = "raw") {
|
|
9106
|
+
return text.replace(HANDLE, (_, credentialId, field, encoding2) => ENCODINGS[encodingOf(encoding2, fallback)](lookup({ credentialId, field })));
|
|
9092
9107
|
}
|
|
9093
9108
|
|
|
9094
9109
|
// src/sinks/secretResolver.ts
|
|
@@ -9106,8 +9121,8 @@ class ResolvedSecrets {
|
|
|
9106
9121
|
this.byKey = new Map(resolved.map((item) => [handleKey(item.ref), item]));
|
|
9107
9122
|
this.redactor = new Redactor(resolved.map(({ ref, value }) => ({ name: handleKey(ref), value })));
|
|
9108
9123
|
}
|
|
9109
|
-
fill(text,
|
|
9110
|
-
return fillHandles(text, (ref) =>
|
|
9124
|
+
fill(text, fallback = "raw") {
|
|
9125
|
+
return fillHandles(text, (ref) => this.valueOf(ref), fallback);
|
|
9111
9126
|
}
|
|
9112
9127
|
valueOf(ref) {
|
|
9113
9128
|
const resolved = this.byKey.get(handleKey(ref));
|
|
@@ -35898,6 +35913,11 @@ function keyTools(parts) {
|
|
|
35898
35913
|
];
|
|
35899
35914
|
}
|
|
35900
35915
|
|
|
35916
|
+
// src/sinks/execSink.ts
|
|
35917
|
+
import { mkdtemp, rm as rm4, writeFile } from "node:fs/promises";
|
|
35918
|
+
import { tmpdir } from "node:os";
|
|
35919
|
+
import { join as join9 } from "node:path";
|
|
35920
|
+
|
|
35901
35921
|
// src/sinks/processRunner.ts
|
|
35902
35922
|
import { spawn as spawn3 } from "node:child_process";
|
|
35903
35923
|
var MAX_CAPTURE_BYTES = 1024 * 1024;
|
|
@@ -35982,74 +36002,9 @@ async function runProcess(request) {
|
|
|
35982
36002
|
});
|
|
35983
36003
|
}
|
|
35984
36004
|
|
|
35985
|
-
// src/sinks/adbSink.ts
|
|
35986
|
-
var ADB_TIMEOUT_MS = 15000;
|
|
35987
|
-
var PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
35988
|
-
var FOCUSED_PACKAGE = /mCurrentFocus=Window\{\S+ \S+ ([^/\s}]+)/;
|
|
35989
|
-
function adbInputCommand(value) {
|
|
35990
|
-
if (!PRINTABLE_ASCII.test(value)) {
|
|
35991
|
-
throw new ClientError("adb input text types printable ASCII only; this value has other characters. The user has to enter it.");
|
|
35992
|
-
}
|
|
35993
|
-
if (value.includes("%s")) {
|
|
35994
|
-
throw new ClientError("adb input text reads %s as a space, so this value cannot be typed exactly. The user has to enter it.");
|
|
35995
|
-
}
|
|
35996
|
-
const quoted = value.replaceAll(" ", "%s").replaceAll("'", `'\\''`);
|
|
35997
|
-
return `input text '${quoted}'
|
|
35998
|
-
`;
|
|
35999
|
-
}
|
|
36000
|
-
|
|
36001
|
-
class AdbSink {
|
|
36002
|
-
resolver;
|
|
36003
|
-
adb;
|
|
36004
|
-
constructor(resolver, adb = "adb") {
|
|
36005
|
-
this.resolver = resolver;
|
|
36006
|
-
this.adb = adb;
|
|
36007
|
-
}
|
|
36008
|
-
async input(args) {
|
|
36009
|
-
if (refsIn(args.text).length === 0) {
|
|
36010
|
-
throw new ClientError("text needs a secret handle: {{secret://<credential_id>/<field>}}");
|
|
36011
|
-
}
|
|
36012
|
-
const target = args.serial === undefined ? [] : ["-s", args.serial];
|
|
36013
|
-
const secrets = await this.resolver.resolve([args.text]);
|
|
36014
|
-
const value = secrets.fill(args.text);
|
|
36015
|
-
const command = adbInputCommand(value);
|
|
36016
|
-
await this.requireFocus(target, args.expect_package);
|
|
36017
|
-
await this.shell(target, command, secrets);
|
|
36018
|
-
return {
|
|
36019
|
-
entered_length: value.length,
|
|
36020
|
-
package: args.expect_package,
|
|
36021
|
-
used: secrets.used()
|
|
36022
|
-
};
|
|
36023
|
-
}
|
|
36024
|
-
async requireFocus(target, expected) {
|
|
36025
|
-
const outcome = await runProcess({
|
|
36026
|
-
argv: [this.adb, ...target, "shell", "dumpsys", "window"],
|
|
36027
|
-
timeoutMs: ADB_TIMEOUT_MS
|
|
36028
|
-
});
|
|
36029
|
-
if (outcome.exitCode !== 0) {
|
|
36030
|
-
throw new ClientError(`adb could not read the focused window: ${outcome.stderr.trim()}`);
|
|
36031
|
-
}
|
|
36032
|
-
const focused = FOCUSED_PACKAGE.exec(outcome.stdout)?.[1];
|
|
36033
|
-
if (focused === undefined) {
|
|
36034
|
-
throw new ClientError("No focused app found on the device (is it locked?); nothing was typed.");
|
|
36035
|
-
}
|
|
36036
|
-
if (focused !== expected) {
|
|
36037
|
-
throw new ClientError(`The focused app is ${focused}, not ${expected}; nothing was typed.`);
|
|
36038
|
-
}
|
|
36039
|
-
}
|
|
36040
|
-
async shell(target, command, secrets) {
|
|
36041
|
-
const outcome = await runProcess({
|
|
36042
|
-
argv: [this.adb, ...target, "shell", "-T"],
|
|
36043
|
-
stdin: command,
|
|
36044
|
-
timeoutMs: ADB_TIMEOUT_MS
|
|
36045
|
-
});
|
|
36046
|
-
if (outcome.exitCode !== 0 || outcome.timedOut) {
|
|
36047
|
-
throw new ClientError(`adb input text failed (exit ${outcome.exitCode}${outcome.timedOut ? ", timed out" : ""}): ${secrets.redact(`${outcome.stderr}${outcome.stdout}`).trim()}`);
|
|
36048
|
-
}
|
|
36049
|
-
}
|
|
36050
|
-
}
|
|
36051
|
-
|
|
36052
36005
|
// src/sinks/execSink.ts
|
|
36006
|
+
var filled = (texts, secrets) => Object.fromEntries(Object.entries(texts).map(([name, text2]) => [name, secrets.fill(text2)]));
|
|
36007
|
+
|
|
36053
36008
|
class ExecSink {
|
|
36054
36009
|
resolver;
|
|
36055
36010
|
constructor(resolver) {
|
|
@@ -36057,108 +36012,49 @@ class ExecSink {
|
|
|
36057
36012
|
}
|
|
36058
36013
|
async run(args) {
|
|
36059
36014
|
if (args.argv.some(hasHandle)) {
|
|
36060
|
-
throw new ClientError("A secret handle cannot go in argv, which other processes can read: pass it in env or
|
|
36015
|
+
throw new ClientError("A secret handle cannot go in argv, which other processes can read: pass it in env, stdin or files.");
|
|
36061
36016
|
}
|
|
36062
36017
|
const env = args.env ?? {};
|
|
36018
|
+
const files = args.files ?? {};
|
|
36019
|
+
const clash = Object.keys(files).find((name) => Object.hasOwn(env, name));
|
|
36020
|
+
if (clash !== undefined) {
|
|
36021
|
+
throw new ClientError(`${clash} is named in both env and files`);
|
|
36022
|
+
}
|
|
36063
36023
|
const secrets = await this.resolver.resolve([
|
|
36064
36024
|
...Object.values(env),
|
|
36025
|
+
...Object.values(files),
|
|
36065
36026
|
args.stdin ?? ""
|
|
36066
36027
|
]);
|
|
36067
36028
|
if (secrets.used().length === 0) {
|
|
36068
|
-
throw new ClientError("secret_exec needs a secret handle in env or
|
|
36069
|
-
}
|
|
36070
|
-
const outcome = await runProcess({
|
|
36071
|
-
argv: args.argv,
|
|
36072
|
-
cwd: args.cwd,
|
|
36073
|
-
env: Object.fromEntries(Object.entries(env).map(([name, value]) => [name, secrets.fill(value)])),
|
|
36074
|
-
stdin: args.stdin === undefined ? undefined : secrets.fill(args.stdin),
|
|
36075
|
-
timeoutMs: args.timeout_s * 1000
|
|
36076
|
-
});
|
|
36077
|
-
const stdout = secrets.shown(outcome.stdout, outcome.stdoutCut);
|
|
36078
|
-
const stderr = secrets.shown(outcome.stderr, outcome.stderrCut);
|
|
36079
|
-
return {
|
|
36080
|
-
exit_code: outcome.exitCode,
|
|
36081
|
-
signal: outcome.signal,
|
|
36082
|
-
timed_out: outcome.timedOut,
|
|
36083
|
-
stdout: stdout.text,
|
|
36084
|
-
stderr: stderr.text,
|
|
36085
|
-
output_truncated: stdout.truncated || stderr.truncated,
|
|
36086
|
-
used: secrets.used()
|
|
36087
|
-
};
|
|
36088
|
-
}
|
|
36089
|
-
}
|
|
36090
|
-
|
|
36091
|
-
// src/sinks/httpSink.ts
|
|
36092
|
-
var MAX_READ_BYTES = 1024 * 1024;
|
|
36093
|
-
async function cappedText(response) {
|
|
36094
|
-
const reader = response.body?.getReader();
|
|
36095
|
-
if (reader === undefined)
|
|
36096
|
-
return { text: "", cut: false };
|
|
36097
|
-
const chunks = [];
|
|
36098
|
-
let size = 0;
|
|
36099
|
-
while (size < MAX_READ_BYTES) {
|
|
36100
|
-
const { done, value } = await reader.read();
|
|
36101
|
-
if (done) {
|
|
36102
|
-
return { text: Buffer.concat(chunks).toString("utf8"), cut: false };
|
|
36029
|
+
throw new ClientError("secret_exec needs a secret handle in env, stdin or files; run other programs without it.");
|
|
36103
36030
|
}
|
|
36104
|
-
|
|
36105
|
-
size += value.length;
|
|
36106
|
-
}
|
|
36107
|
-
await reader.cancel();
|
|
36108
|
-
return {
|
|
36109
|
-
text: Buffer.concat(chunks).subarray(0, MAX_READ_BYTES).toString("utf8"),
|
|
36110
|
-
cut: true
|
|
36111
|
-
};
|
|
36112
|
-
}
|
|
36113
|
-
function causeOf(error48) {
|
|
36114
|
-
if (!(error48 instanceof Error))
|
|
36115
|
-
return String(error48);
|
|
36116
|
-
const cause = error48.cause instanceof Error ? `: ${error48.cause.message}` : "";
|
|
36117
|
-
return `${error48.message}${cause}`;
|
|
36118
|
-
}
|
|
36119
|
-
|
|
36120
|
-
class HttpSink {
|
|
36121
|
-
resolver;
|
|
36122
|
-
constructor(resolver) {
|
|
36123
|
-
this.resolver = resolver;
|
|
36124
|
-
}
|
|
36125
|
-
async send(args) {
|
|
36126
|
-
requireHandlesAfterOrigin(args.url);
|
|
36127
|
-
const headers = args.headers ?? {};
|
|
36128
|
-
const secrets = await this.resolver.resolve([
|
|
36129
|
-
args.url,
|
|
36130
|
-
...Object.keys(headers),
|
|
36131
|
-
...Object.values(headers),
|
|
36132
|
-
args.body ?? ""
|
|
36133
|
-
]);
|
|
36134
|
-
const { response, text: text2, cut } = await this.exchange(args, headers, secrets);
|
|
36135
|
-
const body = secrets.shown(text2, cut);
|
|
36136
|
-
return {
|
|
36137
|
-
status: response.status,
|
|
36138
|
-
headers: Object.fromEntries([...response.headers].map(([name, value]) => [
|
|
36139
|
-
name,
|
|
36140
|
-
secrets.redact(value)
|
|
36141
|
-
])),
|
|
36142
|
-
body: body.text,
|
|
36143
|
-
body_truncated: body.truncated,
|
|
36144
|
-
used: secrets.used()
|
|
36145
|
-
};
|
|
36146
|
-
}
|
|
36147
|
-
async exchange(args, headers, secrets) {
|
|
36031
|
+
const dir = await mkdtemp(join9(tmpdir(), "safekb-secret-"));
|
|
36148
36032
|
try {
|
|
36149
|
-
const
|
|
36150
|
-
|
|
36151
|
-
|
|
36152
|
-
|
|
36153
|
-
|
|
36154
|
-
|
|
36155
|
-
|
|
36156
|
-
|
|
36157
|
-
|
|
36033
|
+
const paths = {};
|
|
36034
|
+
for (const [name, content] of Object.entries(filled(files, secrets))) {
|
|
36035
|
+
paths[name] = join9(dir, name);
|
|
36036
|
+
await writeFile(paths[name], content, { mode: 384, flag: "wx" });
|
|
36037
|
+
}
|
|
36038
|
+
const outcome = await runProcess({
|
|
36039
|
+
argv: args.argv,
|
|
36040
|
+
cwd: args.cwd,
|
|
36041
|
+
env: { ...filled(env, secrets), ...paths },
|
|
36042
|
+
stdin: args.stdin === undefined ? undefined : secrets.fill(args.stdin),
|
|
36043
|
+
timeoutMs: args.timeout_s * 1000
|
|
36158
36044
|
});
|
|
36159
|
-
|
|
36160
|
-
|
|
36161
|
-
|
|
36045
|
+
const stdout = secrets.shown(outcome.stdout, outcome.stdoutCut);
|
|
36046
|
+
const stderr = secrets.shown(outcome.stderr, outcome.stderrCut);
|
|
36047
|
+
return {
|
|
36048
|
+
exit_code: outcome.exitCode,
|
|
36049
|
+
signal: outcome.signal,
|
|
36050
|
+
timed_out: outcome.timedOut,
|
|
36051
|
+
stdout: stdout.text,
|
|
36052
|
+
stderr: stderr.text,
|
|
36053
|
+
output_truncated: stdout.truncated || stderr.truncated,
|
|
36054
|
+
used: secrets.used()
|
|
36055
|
+
};
|
|
36056
|
+
} finally {
|
|
36057
|
+
await rm4(dir, { recursive: true, force: true });
|
|
36162
36058
|
}
|
|
36163
36059
|
}
|
|
36164
36060
|
}
|
|
@@ -36358,30 +36254,32 @@ function documentTools(creator, documents, search, names) {
|
|
|
36358
36254
|
}
|
|
36359
36255
|
|
|
36360
36256
|
// src/tools/sinkTools.ts
|
|
36361
|
-
var
|
|
36362
|
-
var
|
|
36363
|
-
|
|
36364
|
-
|
|
36257
|
+
var ENV_NAME = exports_external.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
|
|
36258
|
+
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.
|
|
36259
|
+
|
|
36260
|
+
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.
|
|
36261
|
+
|
|
36262
|
+
Examples:
|
|
36263
|
+
- HTTP API key: env {"API_KEY": "{{secret://<id>/password}}"}, argv ["uv", "run", "call_api.py"]
|
|
36264
|
+
- 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)
|
|
36265
|
+
- Browser form: env {"PW": "{{secret://<id>/password}}"}, argv ["node", "fill.js"] (your script attaches to the open browser with patchright over CDP)
|
|
36266
|
+
- ssh with a key: files {"KEY_FILE": "{{secret://<id>/custom:private key}}"}, argv ["sh", "-c", "ssh -i \\"$KEY_FILE\\" host uptime"]
|
|
36267
|
+
- ssh with a password: env {"SSHPASS": "{{secret://<id>/password}}"}, argv ["sshpass", "-e", "ssh", "host", "uptime"]
|
|
36268
|
+
- LUKS: stdin "{{secret://<id>/password}}", argv ["sudo", "cryptsetup", "open", "--key-file=-", "/dev/sdb1", "vault"]
|
|
36269
|
+
- Wallet unlock, a LoRa radio write, anything else: your script reads the value from env, stdin or a file
|
|
36270
|
+
- One-time code: env {"OTP": "{{secret://<id>/totp}}"}
|
|
36271
|
+
|
|
36272
|
+
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.`;
|
|
36273
|
+
function sinkTools(exec) {
|
|
36365
36274
|
return [
|
|
36366
|
-
defineTool("secret_exec",
|
|
36275
|
+
defineTool("secret_exec", DESCRIPTION, {
|
|
36367
36276
|
argv: exports_external.array(exports_external.string()).min(1).describe('Program and arguments, e.g. ["uv", "run", "script.py"].'),
|
|
36368
36277
|
cwd: absolutePath("Working directory."),
|
|
36369
|
-
env: exports_external.record(
|
|
36278
|
+
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(),
|
|
36370
36279
|
stdin: exports_external.string().describe("Written to stdin, then closed; may hold handles.").optional(),
|
|
36371
|
-
|
|
36372
|
-
|
|
36373
|
-
|
|
36374
|
-
method: exports_external.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]),
|
|
36375
|
-
url: exports_external.string().url().describe("May hold handles, e.g. in the query."),
|
|
36376
|
-
headers: exports_external.record(exports_external.string(), exports_external.string()).describe('e.g. {"Authorization": "Bearer {{secret://<id>/password}}"}.').optional(),
|
|
36377
|
-
body: exports_external.string().describe("May hold handles.").optional(),
|
|
36378
|
-
timeout_s: timeout(30)
|
|
36379
|
-
}, (args) => http.send(args)),
|
|
36380
|
-
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.`, {
|
|
36381
|
-
text: exports_external.string().min(1).describe("What to type, holding a handle: {{secret://<id>/password}}."),
|
|
36382
|
-
serial: exports_external.string().min(1).describe("adb -s: which device, when several are connected.").optional(),
|
|
36383
|
-
expect_package: exports_external.string().min(1).describe("Type only if this app has the focus, e.g. com.example.bank.")
|
|
36384
|
-
}, (args) => adb.input(args))
|
|
36280
|
+
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(),
|
|
36281
|
+
timeout_s: exports_external.number().int().min(1).max(600).default(60).describe("Seconds.")
|
|
36282
|
+
}, (args) => exec.run(args))
|
|
36385
36283
|
];
|
|
36386
36284
|
}
|
|
36387
36285
|
|
|
@@ -36439,7 +36337,7 @@ A credential with \`sealed: true\` belongs to a seal root. Its title, username,
|
|
|
36439
36337
|
|
|
36440
36338
|
- Reveal a secret only when the task needs it, and prefer get_sealed_totp over revealing a TOTP seed.
|
|
36441
36339
|
- 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.
|
|
36442
|
-
- To use a secret without seeing it (visible or use_only fields): secret_exec
|
|
36340
|
+
- 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.
|
|
36443
36341
|
- 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.`;
|
|
36444
36342
|
function createSealedTools(ctx) {
|
|
36445
36343
|
const services = createSealedServices(ctx);
|
|
@@ -36451,7 +36349,7 @@ function createSealedTools(ctx) {
|
|
|
36451
36349
|
...attachmentTools(services.attachments),
|
|
36452
36350
|
...depositTools(services.deposits, names),
|
|
36453
36351
|
...credentialTools(services.credentials, services.migration, names),
|
|
36454
|
-
...sinkTools(new ExecSink(services.secrets)
|
|
36352
|
+
...sinkTools(new ExecSink(services.secrets))
|
|
36455
36353
|
];
|
|
36456
36354
|
}
|
|
36457
36355
|
|
package/package.json
CHANGED