@sandprivacy/sandgate 0.2.0 → 0.2.2
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 +2 -2
- package/dist/index.js +51 -4
- package/dist/passphrase.js +55 -0
- package/dist/test/core.test.js +9 -1
- package/dist/test/passphrase.test.js +20 -0
- package/dist/test/protect.test.js +18 -0
- package/dist/vault.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ How the trust works: the pairing secret travels once, inside the URL **fragment*
|
|
|
90
90
|
|
|
91
91
|
## Security notes, honestly
|
|
92
92
|
|
|
93
|
-
-
|
|
93
|
+
- MCP clients launch servers non-interactively, so the vault passphrase must come from the environment. `SANDGATE_PASSPHRASE` (the value, cleartext in your config) protects the vault *at rest* — a stolen `vault.enc` alone is useless. For more, `SANDGATE_PASSPHRASE_CMD` runs a command whose stdout is the passphrase, so it can live in your OS secret store: Windows DPAPI (`ConvertFrom-SecureString` once, decrypt in the command), macOS `security find-generic-password`, Linux `secret-tool lookup`, or any password manager CLI. Either way, a fully compromised machine defeats any local secret store — that threat class is out of scope for all of them.
|
|
94
94
|
- Approval taps are only accepted from your own Telegram chat; anything else — including silence — is a deny. Agent-supplied text in approval messages is escaped and truncated.
|
|
95
95
|
- Email content handled by `wait_for_verification` is untrusted third-party input. The tool description tells agents so; only the extracted code and hint-filtered links are returned, never the raw body.
|
|
96
96
|
- Several agents can wait on you at once: approvals are served by a single dispatcher, first tap wins per request.
|
|
@@ -104,7 +104,7 @@ How the trust works: the pairing secret travels once, inside the URL **fragment*
|
|
|
104
104
|
- [ ] OS keychain for the vault passphrase
|
|
105
105
|
- [ ] Slack approval channel with multiple approvers (teams)
|
|
106
106
|
- [ ] Team policies (shared vault, centralized audit)
|
|
107
|
-
- [
|
|
107
|
+
- [x] Framework guides: [Claude Code](docs/integrations/claude-code.md), [browser-use](docs/integrations/browser-use.md), [Playwright MCP](docs/integrations/playwright-mcp.md), [LangGraph](docs/integrations/langgraph.md)
|
|
108
108
|
|
|
109
109
|
## License
|
|
110
110
|
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,9 @@ import { join } from "node:path";
|
|
|
5
5
|
import { read } from "read";
|
|
6
6
|
import { getQuota } from "./sandmail.js";
|
|
7
7
|
import { testImapConnection } from "./inbox.js";
|
|
8
|
+
import { resolvePassphrase, protectPassphraseDpapi, dpapiDecryptCommand } from "./passphrase.js";
|
|
8
9
|
import { auditPath } from "./paths.js";
|
|
9
|
-
import { vaultExists, loadVault, saveVault, } from "./vault.js";
|
|
10
|
+
import { vaultExists, loadVault, saveVault, rekeyVault, } from "./vault.js";
|
|
10
11
|
import { loadConfig, saveConfig } from "./config.js";
|
|
11
12
|
import { normalizeSecret, generateCode } from "./totp.js";
|
|
12
13
|
import { TelegramApprover, discoverChatId } from "./telegram.js";
|
|
@@ -24,6 +25,8 @@ Usage:
|
|
|
24
25
|
sandgate connect-sandmail <api-key> Connect the sandmail inbox backend
|
|
25
26
|
sandgate connect-imap Connect your own IMAP mailbox instead (self-hosted)
|
|
26
27
|
sandgate test-approval Send a test approval to your phone
|
|
28
|
+
sandgate rekey Change the vault passphrase
|
|
29
|
+
sandgate protect Store the passphrase in the OS store (Windows DPAPI)
|
|
27
30
|
sandgate status Show what is configured and active
|
|
28
31
|
sandgate audit [n] Show the last n audit entries (default 20)
|
|
29
32
|
sandgate serve Run the MCP server (stdio)
|
|
@@ -220,6 +223,37 @@ async function cmdConnectImap() {
|
|
|
220
223
|
: "";
|
|
221
224
|
console.log(`IMAP connected (${config.user}@${config.host}).${note}`);
|
|
222
225
|
}
|
|
226
|
+
async function cmdRekey() {
|
|
227
|
+
const prompter = new Prompter();
|
|
228
|
+
const current = await prompter.ask("Current passphrase: ", { hidden: true });
|
|
229
|
+
const next = await prompter.ask("New passphrase: ", { hidden: true });
|
|
230
|
+
const confirm = await prompter.ask("Confirm new passphrase: ", { hidden: true });
|
|
231
|
+
prompter.close();
|
|
232
|
+
if (!next) {
|
|
233
|
+
console.error("A passphrase is required.");
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
if (next !== confirm) {
|
|
237
|
+
console.error("Passphrases do not match.");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
rekeyVault(current, next); // throws "wrong passphrase" if current is bad
|
|
241
|
+
console.log("Vault re-encrypted. Update SANDGATE_PASSPHRASE (or your passphrase command's store) everywhere sandgate serve is launched.");
|
|
242
|
+
}
|
|
243
|
+
async function cmdProtect() {
|
|
244
|
+
const prompter = new Prompter();
|
|
245
|
+
const pass = await prompter.ask("Vault passphrase: ", { hidden: true });
|
|
246
|
+
prompter.close();
|
|
247
|
+
if (!pass) {
|
|
248
|
+
console.error("A passphrase is required.");
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
loadVault(pass); // validate before storing — a typo here would be silent later
|
|
252
|
+
const target = join(sandgateDir(), "pass.dpapi");
|
|
253
|
+
protectPassphraseDpapi(pass, target);
|
|
254
|
+
console.log(`Passphrase verified against the vault and stored, DPAPI-encrypted, at:\n ${target}\n\n` +
|
|
255
|
+
`Point your MCP config at it with:\n SANDGATE_PASSPHRASE_CMD = ${dpapiDecryptCommand(target)}`);
|
|
256
|
+
}
|
|
223
257
|
async function cmdStatus() {
|
|
224
258
|
if (!vaultExists()) {
|
|
225
259
|
console.log("No vault. Run `sandgate init` to get started.");
|
|
@@ -385,16 +419,29 @@ async function main() {
|
|
|
385
419
|
return cmdRelay(args[0]);
|
|
386
420
|
case "pair":
|
|
387
421
|
return cmdPair(args[0]);
|
|
422
|
+
case "rekey":
|
|
423
|
+
return cmdRekey();
|
|
424
|
+
case "protect":
|
|
425
|
+
return cmdProtect();
|
|
388
426
|
case "status":
|
|
389
427
|
return cmdStatus();
|
|
390
428
|
case "audit":
|
|
391
429
|
return cmdAudit(args[0]);
|
|
392
430
|
case undefined:
|
|
393
431
|
case "serve": {
|
|
394
|
-
|
|
432
|
+
let pass;
|
|
433
|
+
try {
|
|
434
|
+
pass = resolvePassphrase(process.env);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
console.error(`SANDGATE_PASSPHRASE_CMD failed: ${err instanceof Error ? err.message : err}`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
395
440
|
if (!pass) {
|
|
396
|
-
console.error("
|
|
397
|
-
'
|
|
441
|
+
console.error("No vault passphrase. MCP clients launch sandgate non-interactively; provide either\n" +
|
|
442
|
+
' SANDGATE_PASSPHRASE the value itself, or\n' +
|
|
443
|
+
" SANDGATE_PASSPHRASE_CMD a command printing it (OS keychain, DPAPI, password manager CLI)\n" +
|
|
444
|
+
"in the MCP server config env.");
|
|
398
445
|
process.exit(1);
|
|
399
446
|
}
|
|
400
447
|
return serve(pass);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the vault passphrase for non-interactive runs (MCP clients
|
|
4
|
+
* launching `sandgate serve`). Two sources, in order:
|
|
5
|
+
*
|
|
6
|
+
* - SANDGATE_PASSPHRASE: the value itself. Simple, but sits in cleartext
|
|
7
|
+
* in whatever config launches the server.
|
|
8
|
+
* - SANDGATE_PASSPHRASE_CMD: a command whose stdout is the passphrase —
|
|
9
|
+
* the git-credential/restic pattern. Point it at the OS secret store:
|
|
10
|
+
* DPAPI on Windows, `security find-generic-password` on macOS,
|
|
11
|
+
* `secret-tool lookup` on Linux, or any password manager CLI.
|
|
12
|
+
*
|
|
13
|
+
* Either way the model never sees it: it lives in the launcher's
|
|
14
|
+
* environment, not in the agent's context.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Windows only: encrypt the passphrase with DPAPI (bound to the current
|
|
18
|
+
* Windows session) and write the blob to filePath. The plaintext travels
|
|
19
|
+
* to PowerShell via stdin — never argv, never env.
|
|
20
|
+
*/
|
|
21
|
+
export function protectPassphraseDpapi(passphrase, filePath) {
|
|
22
|
+
if (process.platform !== "win32") {
|
|
23
|
+
throw new Error("DPAPI is Windows-only. On macOS use the keychain (`security add-generic-password`), on Linux `secret-tool store` — see docs/integrations/claude-code.md.");
|
|
24
|
+
}
|
|
25
|
+
const script = "$plain = [Console]::In.ReadToEnd().TrimEnd([char]13, [char]10); " +
|
|
26
|
+
"if ($plain.Length -eq 0) { exit 2 }; " +
|
|
27
|
+
"$ss = ConvertTo-SecureString $plain -AsPlainText -Force; " +
|
|
28
|
+
`$ss | ConvertFrom-SecureString | Out-File -Encoding ascii '${filePath.replace(/'/g, "''")}'`;
|
|
29
|
+
const result = spawnSync("powershell", ["-NoProfile", "-Command", script], {
|
|
30
|
+
input: passphrase,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
timeout: 30_000,
|
|
33
|
+
});
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
throw new Error(`DPAPI protection failed (exit ${result.status}): ${result.stderr?.toString().slice(0, 300)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The command that decrypts what protectPassphraseDpapi wrote. */
|
|
39
|
+
export function dpapiDecryptCommand(filePath) {
|
|
40
|
+
return `powershell -NoProfile -Command "[Net.NetworkCredential]::new('', (Get-Content '${filePath}' | ConvertTo-SecureString)).Password"`;
|
|
41
|
+
}
|
|
42
|
+
export function resolvePassphrase(env) {
|
|
43
|
+
if (env.SANDGATE_PASSPHRASE)
|
|
44
|
+
return env.SANDGATE_PASSPHRASE;
|
|
45
|
+
const command = env.SANDGATE_PASSPHRASE_CMD;
|
|
46
|
+
if (!command)
|
|
47
|
+
return undefined;
|
|
48
|
+
const output = execSync(command, {
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
timeout: 15_000,
|
|
52
|
+
});
|
|
53
|
+
const pass = output.trim();
|
|
54
|
+
return pass.length > 0 ? pass : undefined;
|
|
55
|
+
}
|
package/dist/test/core.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
// Point sandgate at a throwaway home before importing modules that use it.
|
|
7
7
|
process.env.SANDGATE_HOME = mkdtempSync(join(tmpdir(), "sandgate-test-"));
|
|
8
|
-
const { saveVault, loadVault } = await import("../vault.js");
|
|
8
|
+
const { saveVault, loadVault, rekeyVault } = await import("../vault.js");
|
|
9
9
|
const { generateCode, normalizeSecret } = await import("../totp.js");
|
|
10
10
|
const { loadConfig, saveConfig, totpPolicy } = await import("../config.js");
|
|
11
11
|
test("vault round-trips and rejects a wrong passphrase", () => {
|
|
@@ -17,6 +17,14 @@ test("vault round-trips and rejects a wrong passphrase", () => {
|
|
|
17
17
|
assert.deepEqual(loadVault("correct horse"), data);
|
|
18
18
|
assert.throws(() => loadVault("wrong"), /wrong passphrase/);
|
|
19
19
|
});
|
|
20
|
+
test("rekey re-encrypts under the new passphrase and retires the old one", () => {
|
|
21
|
+
const data = { totp: { "site.com": { secret: "JBSWY3DPEHPK3PXP" } } };
|
|
22
|
+
saveVault("old-pass", data);
|
|
23
|
+
rekeyVault("old-pass", "new-pass");
|
|
24
|
+
assert.deepEqual(loadVault("new-pass"), data);
|
|
25
|
+
assert.throws(() => loadVault("old-pass"), /wrong passphrase/);
|
|
26
|
+
assert.throws(() => rekeyVault("old-pass", "whatever"), /wrong passphrase/);
|
|
27
|
+
});
|
|
20
28
|
test("totp generates stable 6-digit codes", () => {
|
|
21
29
|
const a = generateCode("JBSWY3DPEHPK3PXP");
|
|
22
30
|
const b = generateCode("JBSWY3DPEHPK3PXP");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolvePassphrase } from "../passphrase.js";
|
|
4
|
+
test("direct SANDGATE_PASSPHRASE wins", () => {
|
|
5
|
+
assert.equal(resolvePassphrase({ SANDGATE_PASSPHRASE: "direct", SANDGATE_PASSPHRASE_CMD: "echo nope" }), "direct");
|
|
6
|
+
});
|
|
7
|
+
test("SANDGATE_PASSPHRASE_CMD output is used, trimmed", () => {
|
|
8
|
+
const cmd = `node -e "console.log(' from-command ')"`;
|
|
9
|
+
assert.equal(resolvePassphrase({ SANDGATE_PASSPHRASE_CMD: cmd }), "from-command");
|
|
10
|
+
});
|
|
11
|
+
test("empty command output resolves to undefined", () => {
|
|
12
|
+
const cmd = `node -e "console.log('')"`;
|
|
13
|
+
assert.equal(resolvePassphrase({ SANDGATE_PASSPHRASE_CMD: cmd }), undefined);
|
|
14
|
+
});
|
|
15
|
+
test("neither variable set resolves to undefined", () => {
|
|
16
|
+
assert.equal(resolvePassphrase({}), undefined);
|
|
17
|
+
});
|
|
18
|
+
test("a failing command throws", () => {
|
|
19
|
+
assert.throws(() => resolvePassphrase({ SANDGATE_PASSPHRASE_CMD: `node -e "process.exit(3)"` }));
|
|
20
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { protectPassphraseDpapi, dpapiDecryptCommand } from "../passphrase.js";
|
|
8
|
+
// DPAPI is Windows-only; the CI matrix covers both, so skip cleanly elsewhere.
|
|
9
|
+
const windowsOnly = { skip: process.platform !== "win32" };
|
|
10
|
+
test("DPAPI protect/decrypt round-trips the passphrase", windowsOnly, () => {
|
|
11
|
+
const file = join(mkdtempSync(join(tmpdir(), "sandgate-dpapi-")), "pass.dpapi");
|
|
12
|
+
protectPassphraseDpapi("correct horse battery staple", file);
|
|
13
|
+
const decrypted = execSync(dpapiDecryptCommand(file), { encoding: "utf8" }).trim();
|
|
14
|
+
assert.equal(decrypted, "correct horse battery staple");
|
|
15
|
+
});
|
|
16
|
+
test("protect refuses on non-Windows platforms", { skip: process.platform === "win32" }, () => {
|
|
17
|
+
assert.throws(() => protectPassphraseDpapi("x", "/tmp/x"), /Windows-only/);
|
|
18
|
+
});
|
package/dist/vault.js
CHANGED
|
@@ -25,6 +25,11 @@ export function saveVault(passphrase, data) {
|
|
|
25
25
|
};
|
|
26
26
|
writeFileSync(vaultPath(), JSON.stringify(file), { mode: 0o600 });
|
|
27
27
|
}
|
|
28
|
+
/** Change the vault passphrase in place: decrypt with the old, re-encrypt with the new. */
|
|
29
|
+
export function rekeyVault(oldPassphrase, newPassphrase) {
|
|
30
|
+
const data = loadVault(oldPassphrase);
|
|
31
|
+
saveVault(newPassphrase, data);
|
|
32
|
+
}
|
|
28
33
|
export function loadVault(passphrase) {
|
|
29
34
|
if (!vaultExists()) {
|
|
30
35
|
throw new Error(`No vault found at ${vaultPath()}. Run \`sandgate init\` first.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "The human gateway for AI agents — approvals, 2FA codes and email verification, self-hosted. Your agent asks; you decide; secrets never touch the LLM.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|