@sandprivacy/sandgate 0.1.8 → 0.2.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 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 four tools
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
@@ -94,8 +99,11 @@ 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
- - [ ] Team policies (shared vault, multiple approvers)
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)
99
107
  - [ ] Framework guides: browser-use, LangGraph, Agno
100
108
 
101
109
  ## License
@@ -10,9 +10,10 @@ export class PwaApprover {
10
10
  url(path) {
11
11
  return this.config.relayUrl.replace(/\/$/, "") + path;
12
12
  }
13
- async request(req) {
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
- approved: decision.approved,
38
- decision: decision.approved ? "approved" : "denied",
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 { approved: false, decision: "timeout" };
57
+ return { answer: decision.answer, decision: "answered" };
42
58
  }
43
59
  }
@@ -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 = (pairs.length > 1 ? p.name + " · " : "") + "agent · approval request";
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
- row.appendChild(makeBtn("Approve", "ok", CHECK, id));
605
- row.appendChild(makeBtn("Deny", "no", CROSS, id));
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 makeBtn(label, cls, icon, id) {
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 = async function () {
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 " +
@@ -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.8",
3
+ "version": "0.2.0",
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",