@sandprivacy/sandgate 0.1.8 → 0.2.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 +13 -5
- package/dist/index.js +13 -3
- package/dist/passphrase.js +29 -0
- package/dist/pwa-approver.js +23 -7
- package/dist/relay/pwa-page.js +72 -32
- package/dist/server.js +37 -0
- package/dist/test/passphrase.test.js +20 -0
- package/dist/test/pwa-browser.test.js +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,13 +44,18 @@ claude mcp add sandgate -e SANDGATE_PASSPHRASE=your-passphrase -- sandgate serve
|
|
|
44
44
|
|
|
45
45
|
That's it. Your agent now has four new tools.
|
|
46
46
|
|
|
47
|
-
## The
|
|
47
|
+
## The five tools
|
|
48
48
|
|
|
49
49
|
- **`request_approval`** — "May I pay €300 on this site?" → push to your phone → approve/deny. No answer = denied.
|
|
50
|
+
- **`ask_human`** — "What's the code you received by SMS?" → your phone shows an input field → your typed answer returns over the same encrypted channel. Covers SMS codes on your *real* number (no VoIP numbers that sites reject), security questions, choices.
|
|
50
51
|
- **`get_totp`** — the current 6-digit code for a domain. Per-domain policy: `auto` (trusted sites), `approve` (buzz first — the default), or `deny`. The seed itself is never exposed.
|
|
51
52
|
- **`create_identity`** — a disposable email inbox so the agent can sign up for services without your real address.
|
|
52
53
|
- **`wait_for_verification`** — long-polls that inbox and returns the extracted verification code and links the moment they arrive.
|
|
53
54
|
|
|
55
|
+
## Recipes
|
|
56
|
+
|
|
57
|
+
**CAPTCHAs.** sandgate will never auto-solve a CAPTCHA — that's the point of a CAPTCHA. The pattern that works today, by composition: tell your agent that on hitting one it should call `request_approval("CAPTCHA on <site> — solve it at the computer, then approve to continue")`. Your phone buzzes, you solve it where the browser is, you tap approve, the agent resumes. Same behavior as OpenAI's Operator, plus the notification.
|
|
58
|
+
|
|
54
59
|
## Policies
|
|
55
60
|
|
|
56
61
|
```bash
|
|
@@ -85,7 +90,7 @@ How the trust works: the pairing secret travels once, inside the URL **fragment*
|
|
|
85
90
|
|
|
86
91
|
## Security notes, honestly
|
|
87
92
|
|
|
88
|
-
-
|
|
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.
|
|
89
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.
|
|
90
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.
|
|
91
96
|
- Several agents can wait on you at once: approvals are served by a single dispatcher, first tap wins per request.
|
|
@@ -94,9 +99,12 @@ How the trust works: the pairing secret travels once, inside the URL **fragment*
|
|
|
94
99
|
|
|
95
100
|
- [x] Generic IMAP backend for verification emails (`sandgate connect-imap`)
|
|
96
101
|
- [x] `sandgate audit` — pretty-print the audit trail
|
|
97
|
-
- [x] Mobile PWA with end-to-end-encrypted push (`sandgate relay` + `sandgate pair`)
|
|
98
|
-
- [
|
|
99
|
-
- [ ]
|
|
102
|
+
- [x] Mobile PWA with end-to-end-encrypted push (`sandgate relay` + `sandgate pair`), multi-vault, on-device history
|
|
103
|
+
- [x] `ask_human` — free-text answers (SMS codes on your real number, security questions)
|
|
104
|
+
- [ ] OS keychain for the vault passphrase
|
|
105
|
+
- [ ] Slack approval channel with multiple approvers (teams)
|
|
106
|
+
- [ ] Team policies (shared vault, centralized audit)
|
|
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)
|
|
100
108
|
|
|
101
109
|
## License
|
|
102
110
|
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ 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 } from "./passphrase.js";
|
|
8
9
|
import { auditPath } from "./paths.js";
|
|
9
10
|
import { vaultExists, loadVault, saveVault, } from "./vault.js";
|
|
10
11
|
import { loadConfig, saveConfig } from "./config.js";
|
|
@@ -391,10 +392,19 @@ async function main() {
|
|
|
391
392
|
return cmdAudit(args[0]);
|
|
392
393
|
case undefined:
|
|
393
394
|
case "serve": {
|
|
394
|
-
|
|
395
|
+
let pass;
|
|
396
|
+
try {
|
|
397
|
+
pass = resolvePassphrase(process.env);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
console.error(`SANDGATE_PASSPHRASE_CMD failed: ${err instanceof Error ? err.message : err}`);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
395
403
|
if (!pass) {
|
|
396
|
-
console.error("
|
|
397
|
-
'
|
|
404
|
+
console.error("No vault passphrase. MCP clients launch sandgate non-interactively; provide either\n" +
|
|
405
|
+
' SANDGATE_PASSPHRASE the value itself, or\n' +
|
|
406
|
+
" SANDGATE_PASSPHRASE_CMD a command printing it (OS keychain, DPAPI, password manager CLI)\n" +
|
|
407
|
+
"in the MCP server config env.");
|
|
398
408
|
process.exit(1);
|
|
399
409
|
}
|
|
400
410
|
return serve(pass);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execSync } 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
|
+
export function resolvePassphrase(env) {
|
|
17
|
+
if (env.SANDGATE_PASSPHRASE)
|
|
18
|
+
return env.SANDGATE_PASSPHRASE;
|
|
19
|
+
const command = env.SANDGATE_PASSPHRASE_CMD;
|
|
20
|
+
if (!command)
|
|
21
|
+
return undefined;
|
|
22
|
+
const output = execSync(command, {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
timeout: 15_000,
|
|
26
|
+
});
|
|
27
|
+
const pass = output.trim();
|
|
28
|
+
return pass.length > 0 ? pass : undefined;
|
|
29
|
+
}
|
package/dist/pwa-approver.js
CHANGED
|
@@ -10,9 +10,10 @@ export class PwaApprover {
|
|
|
10
10
|
url(path) {
|
|
11
11
|
return this.config.relayUrl.replace(/\/$/, "") + path;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/** Post a sealed request and long-poll its sealed decision (or null on timeout). */
|
|
14
|
+
async roundTrip(kind, req) {
|
|
14
15
|
const requestId = randomBytes(16).toString("base64url");
|
|
15
|
-
const sealed = seal(this.key, { title: req.title, body: req.body, timeoutSec: req.timeoutSec, ts: Date.now() }, aadForRequest(requestId));
|
|
16
|
+
const sealed = seal(this.key, { kind, title: req.title, body: req.body, timeoutSec: req.timeoutSec, ts: Date.now() }, aadForRequest(requestId));
|
|
16
17
|
const post = await fetch(this.url("/api/request"), {
|
|
17
18
|
method: "POST",
|
|
18
19
|
headers: { "Content-Type": "application/json" },
|
|
@@ -33,11 +34,26 @@ export class PwaApprover {
|
|
|
33
34
|
const decision = open(this.key, payload, aadForDecision(requestId));
|
|
34
35
|
if (decision.requestId !== requestId)
|
|
35
36
|
continue; // belt and suspenders; AAD already binds it
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
return decision;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
async request(req) {
|
|
42
|
+
const decision = await this.roundTrip("approval", req);
|
|
43
|
+
if (!decision)
|
|
44
|
+
return { approved: false, decision: "timeout" };
|
|
45
|
+
return {
|
|
46
|
+
approved: decision.approved,
|
|
47
|
+
decision: decision.approved ? "approved" : "denied",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async ask(req) {
|
|
51
|
+
const decision = await this.roundTrip("input", req);
|
|
52
|
+
if (!decision)
|
|
53
|
+
return { answer: null, decision: "timeout" };
|
|
54
|
+
if (!decision.approved || typeof decision.answer !== "string") {
|
|
55
|
+
return { answer: null, decision: "denied" };
|
|
40
56
|
}
|
|
41
|
-
return {
|
|
57
|
+
return { answer: decision.answer, decision: "answered" };
|
|
42
58
|
}
|
|
43
59
|
}
|
package/dist/relay/pwa-page.js
CHANGED
|
@@ -211,8 +211,18 @@ export const PWA_HTML = `<!doctype html>
|
|
|
211
211
|
.hrow .t { flex: 1; color: #cfc6b2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
212
212
|
.hrow .d { font-weight: 650; font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; }
|
|
213
213
|
.d.approved { color: #7fbf9a; }
|
|
214
|
+
.d.answered { color: #7fbf9a; }
|
|
214
215
|
.d.denied { color: #d98a76; }
|
|
215
216
|
.d.expired { color: var(--soft); }
|
|
217
|
+
|
|
218
|
+
.answer-input {
|
|
219
|
+
width: 100%; box-sizing: border-box; padding: 12px 14px; margin: 0 0 12px;
|
|
220
|
+
background: var(--panel-raised); border: 1px solid var(--line); border-radius: 10px;
|
|
221
|
+
/* 16px minimum: below that, iOS Safari auto-zooms into focused inputs. */
|
|
222
|
+
color: var(--ink); font: 16px ui-monospace, monospace;
|
|
223
|
+
}
|
|
224
|
+
.answer-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
225
|
+
.answer-input:disabled { opacity: .5; }
|
|
216
226
|
</style>
|
|
217
227
|
</head>
|
|
218
228
|
<body>
|
|
@@ -582,17 +592,29 @@ export const PWA_HTML = `<!doctype html>
|
|
|
582
592
|
}
|
|
583
593
|
|
|
584
594
|
function addCard(id, p, requestId, req) {
|
|
595
|
+
var isInput = req.kind === "input";
|
|
585
596
|
var card = document.createElement("div");
|
|
586
597
|
card.className = "card";
|
|
587
598
|
|
|
588
599
|
var who = document.createElement("div"); who.className = "who";
|
|
589
|
-
who.textContent =
|
|
600
|
+
who.textContent =
|
|
601
|
+
(pairs.length > 1 ? p.name + " · " : "") +
|
|
602
|
+
(isInput ? "agent · question" : "agent · approval request");
|
|
590
603
|
card.appendChild(who);
|
|
591
604
|
var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
|
|
592
605
|
// NOTE: never name this variable p — var is function-scoped and would
|
|
593
606
|
// shadow the pairing parameter for the rest of addCard (real bug once).
|
|
594
607
|
if (req.body) { var bodyP = document.createElement("p"); bodyP.textContent = req.body; card.appendChild(bodyP); }
|
|
595
608
|
|
|
609
|
+
var input = null;
|
|
610
|
+
if (isInput) {
|
|
611
|
+
input = document.createElement("input");
|
|
612
|
+
input.className = "answer-input";
|
|
613
|
+
input.placeholder = "Your answer";
|
|
614
|
+
input.autocomplete = "off";
|
|
615
|
+
card.appendChild(input);
|
|
616
|
+
}
|
|
617
|
+
|
|
596
618
|
var timer = document.createElement("div"); timer.className = "timer";
|
|
597
619
|
var left = document.createElement("div"); left.className = "left";
|
|
598
620
|
var bar = document.createElement("div"); bar.className = "bar";
|
|
@@ -601,13 +623,32 @@ export const PWA_HTML = `<!doctype html>
|
|
|
601
623
|
card.appendChild(timer);
|
|
602
624
|
|
|
603
625
|
var row = document.createElement("div"); row.className = "row";
|
|
604
|
-
|
|
605
|
-
|
|
626
|
+
if (isInput) {
|
|
627
|
+
var sendBtn = makeActionBtn("Send", "ok", CHECK, function (btn) {
|
|
628
|
+
var value = input.value.trim();
|
|
629
|
+
if (!value) { input.focus(); return; }
|
|
630
|
+
submitDecision(id, { requestId: requestId, approved: true, answer: value, ts: Date.now() }, "answered", btn);
|
|
631
|
+
});
|
|
632
|
+
input.addEventListener("keydown", function (e) {
|
|
633
|
+
if (e.key === "Enter") sendBtn.click();
|
|
634
|
+
});
|
|
635
|
+
row.appendChild(sendBtn);
|
|
636
|
+
row.appendChild(makeActionBtn("Deny", "no", CROSS, function (btn) {
|
|
637
|
+
submitDecision(id, { requestId: requestId, approved: false, ts: Date.now() }, "denied", btn);
|
|
638
|
+
}));
|
|
639
|
+
} else {
|
|
640
|
+
row.appendChild(makeActionBtn("Approve", "ok", CHECK, function (btn) {
|
|
641
|
+
submitDecision(id, { requestId: requestId, approved: true, ts: Date.now() }, "approved", btn);
|
|
642
|
+
}));
|
|
643
|
+
row.appendChild(makeActionBtn("Deny", "no", CROSS, function (btn) {
|
|
644
|
+
submitDecision(id, { requestId: requestId, approved: false, ts: Date.now() }, "denied", btn);
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
606
647
|
card.appendChild(row);
|
|
607
648
|
|
|
608
649
|
ensureEmpty(false);
|
|
609
650
|
listEl.appendChild(card);
|
|
610
|
-
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, req: req, pair: p, requestId: requestId, done: false };
|
|
651
|
+
cards[id] = { el: card, leftEl: left, fillEl: fill, barEl: bar, rowEl: row, inputEl: input, req: req, pair: p, requestId: requestId, done: false };
|
|
611
652
|
tickOne(cards[id]);
|
|
612
653
|
}
|
|
613
654
|
|
|
@@ -619,6 +660,7 @@ export const PWA_HTML = `<!doctype html>
|
|
|
619
660
|
c.done = true;
|
|
620
661
|
c.el.classList.add("expired");
|
|
621
662
|
c.rowEl.remove();
|
|
663
|
+
if (c.inputEl) c.inputEl.disabled = true;
|
|
622
664
|
c.leftEl.textContent = "expired — denied";
|
|
623
665
|
c.fillEl.style.width = "0%";
|
|
624
666
|
recordHist(histLabel(c), "expired");
|
|
@@ -633,41 +675,39 @@ export const PWA_HTML = `<!doctype html>
|
|
|
633
675
|
for (var id in cards) tickOne(cards[id]);
|
|
634
676
|
}, 1000);
|
|
635
677
|
|
|
636
|
-
function
|
|
678
|
+
function makeActionBtn(label, cls, icon, onTap) {
|
|
637
679
|
var b = document.createElement("button");
|
|
638
680
|
b.className = cls;
|
|
639
681
|
b.innerHTML = icon + "<span></span>";
|
|
640
682
|
b.querySelector("span").textContent = label;
|
|
641
|
-
b.onclick =
|
|
642
|
-
var c = cards[id];
|
|
643
|
-
if (!c || c.done) return;
|
|
644
|
-
b.disabled = true;
|
|
645
|
-
try {
|
|
646
|
-
var payload = await sealPayload(
|
|
647
|
-
c.pair,
|
|
648
|
-
{ requestId: c.requestId, approved: cls === "ok", ts: Date.now() },
|
|
649
|
-
"dec:" + c.requestId
|
|
650
|
-
);
|
|
651
|
-
var res = await fetch("/api/decision", {
|
|
652
|
-
method: "POST",
|
|
653
|
-
headers: { "Content-Type": "application/json" },
|
|
654
|
-
body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
|
|
655
|
-
});
|
|
656
|
-
if (!res.ok) throw new Error("relay answered HTTP " + res.status);
|
|
657
|
-
if (cards[id]) {
|
|
658
|
-
recordHist(histLabel(c), cls === "ok" ? "approved" : "denied");
|
|
659
|
-
cards[id].el.remove();
|
|
660
|
-
delete cards[id];
|
|
661
|
-
}
|
|
662
|
-
ensureEmpty(Object.keys(cards).length === 0);
|
|
663
|
-
} catch (err) {
|
|
664
|
-
b.disabled = false;
|
|
665
|
-
alert("Could not send your decision: " + (err && err.message ? err.message : err));
|
|
666
|
-
}
|
|
667
|
-
};
|
|
683
|
+
b.onclick = function () { onTap(b); };
|
|
668
684
|
return b;
|
|
669
685
|
}
|
|
670
686
|
|
|
687
|
+
async function submitDecision(id, decisionBody, histDecision, btn) {
|
|
688
|
+
var c = cards[id];
|
|
689
|
+
if (!c || c.done) return;
|
|
690
|
+
btn.disabled = true;
|
|
691
|
+
try {
|
|
692
|
+
var payload = await sealPayload(c.pair, decisionBody, "dec:" + c.requestId);
|
|
693
|
+
var res = await fetch("/api/decision", {
|
|
694
|
+
method: "POST",
|
|
695
|
+
headers: { "Content-Type": "application/json" },
|
|
696
|
+
body: JSON.stringify({ pairId: c.pair.pairId, requestId: c.requestId, payload: payload }),
|
|
697
|
+
});
|
|
698
|
+
if (!res.ok) throw new Error("relay answered HTTP " + res.status);
|
|
699
|
+
if (cards[id]) {
|
|
700
|
+
recordHist(histLabel(c), histDecision);
|
|
701
|
+
cards[id].el.remove();
|
|
702
|
+
delete cards[id];
|
|
703
|
+
}
|
|
704
|
+
ensureEmpty(Object.keys(cards).length === 0);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
btn.disabled = false;
|
|
707
|
+
alert("Could not send your decision: " + (err && err.message ? err.message : err));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
671
711
|
function histLabel(c) {
|
|
672
712
|
return (pairs.length > 1 ? c.pair.name + ": " : "") + c.req.title;
|
|
673
713
|
}
|
package/dist/server.js
CHANGED
|
@@ -62,6 +62,43 @@ export async function serve(passphrase) {
|
|
|
62
62
|
return refusal(String(err));
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
|
+
server.registerTool("ask_human", {
|
|
66
|
+
title: "Ask the human a question",
|
|
67
|
+
description: "Ask the human for a short piece of information only they have: a " +
|
|
68
|
+
"code received by SMS on their real phone number, the answer to a " +
|
|
69
|
+
"security question, a choice between options. Their answer is typed " +
|
|
70
|
+
"on their phone and returned over the same end-to-end-encrypted " +
|
|
71
|
+
"channel as approvals. No answer within the timeout means denied.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
question: z.string().describe("The question shown to the human"),
|
|
74
|
+
context: z.string().optional().describe("Extra context shown under the question"),
|
|
75
|
+
timeout_sec: z.number().int().min(10).max(600).optional(),
|
|
76
|
+
},
|
|
77
|
+
}, async ({ question, context, timeout_sec }) => {
|
|
78
|
+
try {
|
|
79
|
+
const approver = needApprover();
|
|
80
|
+
if (!approver.ask) {
|
|
81
|
+
return refusal("Input requests need the PWA approval channel. Pair a phone with `sandgate pair <relay-url>`.");
|
|
82
|
+
}
|
|
83
|
+
const result = await approver.ask({
|
|
84
|
+
title: question,
|
|
85
|
+
body: context,
|
|
86
|
+
timeoutSec: timeout_sec ?? config.approvalTimeoutSec,
|
|
87
|
+
});
|
|
88
|
+
// The answer itself is never audited — it may be a code or a secret.
|
|
89
|
+
audit({ tool: "ask_human", action: question, decision: result.decision === "answered" ? "approved" : result.decision === "denied" ? "denied" : "timeout" });
|
|
90
|
+
if (result.decision !== "answered") {
|
|
91
|
+
return refusal(result.decision === "timeout"
|
|
92
|
+
? "The human did not answer in time."
|
|
93
|
+
: "The human declined to answer.");
|
|
94
|
+
}
|
|
95
|
+
return text({ ok: true, answered: true, answer: result.answer });
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
audit({ tool: "ask_human", action: question, decision: "error", detail: String(err) });
|
|
99
|
+
return refusal(String(err));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
65
102
|
server.registerTool("get_totp", {
|
|
66
103
|
title: "Get a 2FA code",
|
|
67
104
|
description: "Get the current 6-digit 2FA (TOTP) code for a domain whose seed the " +
|
|
@@ -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
|
+
});
|
|
@@ -104,6 +104,78 @@ for (const scenario of ["fragment", "legacy-storage"]) {
|
|
|
104
104
|
}
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
|
+
test("ask_human: the input card round-trips a typed answer", async () => {
|
|
108
|
+
const relay = await startRelay({
|
|
109
|
+
port: 0,
|
|
110
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
111
|
+
});
|
|
112
|
+
const relayUrl = `http://localhost:${relay.port}`;
|
|
113
|
+
try {
|
|
114
|
+
const pairing = newPairing();
|
|
115
|
+
const { window, alerts, close } = await loadPage(relayUrl, {
|
|
116
|
+
hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
const approver = new PwaApprover({
|
|
120
|
+
relayUrl,
|
|
121
|
+
pairId: pairing.pairId,
|
|
122
|
+
secret: pairing.secret,
|
|
123
|
+
});
|
|
124
|
+
const asked = approver.ask({
|
|
125
|
+
title: "What is the SMS code?",
|
|
126
|
+
body: "Sent to your real number",
|
|
127
|
+
timeoutSec: 15,
|
|
128
|
+
});
|
|
129
|
+
const input = await waitFor(() => window.document.querySelector(".card .answer-input"));
|
|
130
|
+
assert.match(window.document.querySelector(".card .who").textContent, /question/);
|
|
131
|
+
input.value = " 847291 ";
|
|
132
|
+
const sendBtn = window.document.querySelector(".card button.ok");
|
|
133
|
+
sendBtn.click();
|
|
134
|
+
const result = await asked;
|
|
135
|
+
assert.deepEqual(alerts, [], `page alerted: ${alerts.join(" | ")}`);
|
|
136
|
+
assert.deepEqual(result, { answer: "847291", decision: "answered" });
|
|
137
|
+
await waitFor(() => window.document.querySelector(".hrow .d.answered"));
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
relay.close();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
test("ask_human: denying returns no answer", async () => {
|
|
148
|
+
const relay = await startRelay({
|
|
149
|
+
port: 0,
|
|
150
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
151
|
+
});
|
|
152
|
+
const relayUrl = `http://localhost:${relay.port}`;
|
|
153
|
+
try {
|
|
154
|
+
const pairing = newPairing();
|
|
155
|
+
const { window, alerts, close } = await loadPage(relayUrl, {
|
|
156
|
+
hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const approver = new PwaApprover({
|
|
160
|
+
relayUrl,
|
|
161
|
+
pairId: pairing.pairId,
|
|
162
|
+
secret: pairing.secret,
|
|
163
|
+
});
|
|
164
|
+
const asked = approver.ask({ title: "Secret question", timeoutSec: 15 });
|
|
165
|
+
const denyBtn = await waitFor(() => window.document.querySelector(".card button.no"));
|
|
166
|
+
denyBtn.click();
|
|
167
|
+
const result = await asked;
|
|
168
|
+
assert.deepEqual(result, { answer: null, decision: "denied" });
|
|
169
|
+
assert.deepEqual(alerts, [], `page alerted: ${alerts.join(" | ")}`);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
relay.close();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
107
179
|
test("Deny works and corrupt stored pairings do not break the page", async () => {
|
|
108
180
|
const relay = await startRelay({
|
|
109
181
|
port: 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|