@sandprivacy/sandgate 0.1.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 ADDED
@@ -0,0 +1,103 @@
1
+ # sandgate
2
+
3
+ **The human gateway for AI agents.** Approvals, 2FA codes and email verification — one self-hosted MCP server. Your agent asks; your phone buzzes; you decide. Secrets never touch the LLM.
4
+
5
+ <!-- TODO(launch): hero GIF — agent hits a 2FA screen → phone buzzes → tap Approve → agent finishes the task -->
6
+
7
+ ```
8
+ Agent: "I need the 2FA code for github.com"
9
+
10
+
11
+ sandgate ──► your phone: [✅ Approve] [❌ Deny]
12
+
13
+ ▼ (approved)
14
+ Agent gets: 483 921 ← a 30-second code
15
+ Agent never gets: the seed ← stays AES-256-GCM encrypted in your vault
16
+ ```
17
+
18
+ ## Why
19
+
20
+ Agents stall on everything that needs a human: TOTP prompts, verification emails, magic links, "are you sure?" moments. Today the workarounds are ugly — TOTP seeds in plaintext `.env` files, agents with full access to your inbox, or a human babysitting the terminal. sandgate replaces all of that with one MCP server any agent can call:
21
+
22
+ | | DIY (`.env` + custom tools) | sandgate |
23
+ |---|---|---|
24
+ | TOTP seed storage | plaintext | encrypted vault (AES-256-GCM, scrypt) |
25
+ | What the LLM sees | the seed | a 30-second code, after policy check |
26
+ | Sensitive actions | agent decides | your phone decides |
27
+ | Verification emails | build it yourself | `create_identity` + `wait_for_verification` |
28
+ | Audit trail | none | every request, decision and outcome |
29
+
30
+ ## Quickstart
31
+
32
+ ```bash
33
+ npm install -g @sandprivacy/sandgate
34
+ sandgate init # vault passphrase + Telegram bot + inbox backend
35
+ sandgate add-totp github.com JBSWY3DPEHPK3PXP
36
+ sandgate test-approval # your phone should buzz
37
+ ```
38
+
39
+ Register it with your agent (Claude Code shown; any MCP client works):
40
+
41
+ ```bash
42
+ claude mcp add sandgate -e SANDGATE_PASSPHRASE=your-passphrase -- sandgate serve
43
+ ```
44
+
45
+ That's it. Your agent now has four new tools.
46
+
47
+ ## The four tools
48
+
49
+ - **`request_approval`** — "May I pay €300 on this site?" → push to your phone → approve/deny. No answer = denied.
50
+ - **`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
+ - **`create_identity`** — a disposable email inbox so the agent can sign up for services without your real address.
52
+ - **`wait_for_verification`** — long-polls that inbox and returns the extracted verification code and links the moment they arrive.
53
+
54
+ ## Policies
55
+
56
+ ```bash
57
+ sandgate policy github.com auto # trusted: no buzz, code released instantly
58
+ sandgate policy mybank.com deny # never
59
+ # everything else defaults to "approve" — your phone decides
60
+ ```
61
+
62
+ ## What's in `~/.sandgate/`
63
+
64
+ - `vault.enc` — TOTP seeds, bot token, API keys. AES-256-GCM, key derived from your passphrase with scrypt. Nothing sensitive is ever written in clear.
65
+ - `config.json` — policies and preferences, plaintext, hand-editable.
66
+ - `audit.jsonl` — append-only log of every request: which tool, which domain, what was decided, when. Codes and secrets are never logged.
67
+
68
+ ## Design principles
69
+
70
+ 1. **Zero disclosure.** The LLM sees derived, short-lived values (a 6-digit code, an approval verdict) — never seeds, tokens or passwords.
71
+ 2. **Deny by default.** Unknown domains refuse. Unanswered approvals refuse. Policy gaps refuse.
72
+ 3. **Self-hosted.** Runs on your machine; the vault and the audit trail never leave it. The email backend is pluggable: [sandmail](https://sandmail.dev) works out of the box (managed disposable inboxes), or bring your own mailbox with `sandgate connect-imap` — identities become plus-addressed aliases (`you+sg1a2b@domain`) and codes/links are extracted locally.
73
+ 4. **Everything audited.** If an agent asked for it, it's in the log.
74
+
75
+ ## The PWA approval channel (E2EE)
76
+
77
+ Telegram is the quick start; the PWA is the destination. Run your own relay and pair your phone:
78
+
79
+ ```bash
80
+ sandgate relay # serves the PWA + forwards sealed blobs (port 8787)
81
+ sandgate pair https://your-relay # prints a link + QR — open it on your phone
82
+ ```
83
+
84
+ How the trust works: the pairing secret travels once, inside the URL **fragment** (never sent to any server). Both ends derive an AES-256-GCM key (HKDF); every approval request and every tap is sealed with the request id bound into the AAD. The relay stores and forwards blobs it cannot read, and cannot forge — a malicious relay can at worst drop or delay an answer, which is just a deny. Push notifications wake the phone; if push is unavailable the PWA polls while open. A real phone needs the relay behind TLS (service workers require it); `http://localhost:8787` works for a desktop-browser test.
85
+
86
+ ## Security notes, honestly
87
+
88
+ - `SANDGATE_PASSPHRASE` in the MCP client config is a deliberate tradeoff: MCP clients launch servers non-interactively, so the passphrase lives in your agent's config file. It protects the vault *at rest* (a stolen `vault.enc` alone is useless); OS keychain integration is on the roadmap.
89
+ - 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
+ - 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
+ - Several agents can wait on you at once: approvals are served by a single dispatcher, first tap wins per request.
92
+
93
+ ## Roadmap
94
+
95
+ - [x] Generic IMAP backend for verification emails (`sandgate connect-imap`)
96
+ - [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)
99
+ - [ ] Framework guides: browser-use, LangGraph, Agno
100
+
101
+ ## License
102
+
103
+ AGPL-3.0. Part of the [sandprivacy](https://github.com/sandprivacy) suite — your data and your agents, under your control.
package/dist/audit.js ADDED
@@ -0,0 +1,6 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { auditPath } from "./paths.js";
3
+ export function audit(event) {
4
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
5
+ appendFileSync(auditPath(), line + "\n");
6
+ }
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { configPath } from "./paths.js";
3
+ export const DEFAULT_CONFIG = {
4
+ policies: {
5
+ totp: {},
6
+ totpDefault: "approve",
7
+ verificationDefault: "auto",
8
+ identityDefault: "auto",
9
+ },
10
+ approvalTimeoutSec: 120,
11
+ };
12
+ export function loadConfig() {
13
+ if (!existsSync(configPath()))
14
+ return structuredClone(DEFAULT_CONFIG);
15
+ const raw = JSON.parse(readFileSync(configPath(), "utf8"));
16
+ return {
17
+ ...structuredClone(DEFAULT_CONFIG),
18
+ ...raw,
19
+ policies: { ...structuredClone(DEFAULT_CONFIG.policies), ...raw.policies },
20
+ };
21
+ }
22
+ export function saveConfig(config) {
23
+ writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n");
24
+ }
25
+ export function totpPolicy(config, domain) {
26
+ return config.policies.totp[domain] ?? config.policies.totpDefault;
27
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Local OTP/verification extraction for the IMAP backend (sandmail does
3
+ * this server-side; self-hosters get the same result locally).
4
+ */
5
+ const CODE_CONTEXT = /(?:code|otp|pin|password|passcode|vérification|verification|confirm|2fa|authentif)/i;
6
+ const LINK_HINT = /(?:verify|confirm|activate|validate|auth|token=|code=)/i;
7
+ export function extractVerification(subject, text) {
8
+ const links = [];
9
+ for (const match of text.matchAll(/https?:\/\/[^\s<>"')\]]+/g)) {
10
+ if (LINK_HINT.test(match[0]) && !links.includes(match[0]))
11
+ links.push(match[0]);
12
+ }
13
+ // Prefer codes near context words; fall back to a lone 4-8 digit group in
14
+ // the subject (the "Your code is 774412" pattern).
15
+ const haystacks = [subject, text];
16
+ for (const haystack of haystacks) {
17
+ for (const line of haystack.split(/\r?\n/)) {
18
+ if (!CODE_CONTEXT.test(line))
19
+ continue;
20
+ const m = line.match(/\b(\d{4,8})\b/);
21
+ if (m)
22
+ return { code: m[1], links };
23
+ }
24
+ }
25
+ const subjectOnly = subject.match(/\b(\d{4,8})\b/);
26
+ if (subjectOnly)
27
+ return { code: subjectOnly[1], links };
28
+ // Standalone code on its own line (many emails put the code alone).
29
+ const alone = text.match(/^\s*(\d{4,8})\s*$/m);
30
+ return { code: alone ? alone[1] : null, links };
31
+ }
package/dist/inbox.js ADDED
@@ -0,0 +1,104 @@
1
+ import { ImapFlow } from "imapflow";
2
+ import { simpleParser } from "mailparser";
3
+ import { randomBytes } from "node:crypto";
4
+ import * as sandmail from "./sandmail.js";
5
+ import { extractVerification } from "./extract.js";
6
+ export function backendFromVault(vault) {
7
+ if (vault.sandmail)
8
+ return new SandmailBackend(vault.sandmail.apiKey);
9
+ if (vault.imap)
10
+ return new ImapBackend(vault.imap);
11
+ return null;
12
+ }
13
+ class SandmailBackend {
14
+ apiKey;
15
+ kind = "sandmail";
16
+ constructor(apiKey) {
17
+ this.apiKey = apiKey;
18
+ }
19
+ createIdentity(ttlHours) {
20
+ return sandmail.createInbox(this.apiKey, { ttlHours });
21
+ }
22
+ async waitForVerification(email, timeoutSec) {
23
+ const res = await sandmail.waitForOTP(this.apiKey, email, timeoutSec);
24
+ return {
25
+ found: res.found,
26
+ timedOut: res.timedOut,
27
+ code: res.code,
28
+ from: res.from,
29
+ subject: res.subject,
30
+ links: res.verificationLinks ?? [],
31
+ };
32
+ }
33
+ }
34
+ class ImapBackend {
35
+ config;
36
+ kind = "imap";
37
+ constructor(config) {
38
+ this.config = config;
39
+ }
40
+ async createIdentity() {
41
+ const base = this.config.baseEmail ?? this.config.user;
42
+ const [local, domain] = base.split("@");
43
+ if (!domain)
44
+ throw new Error(`Cannot derive an alias from "${base}".`);
45
+ const tag = randomBytes(3).toString("hex");
46
+ return { email: `${local}+sg${tag}@${domain}`, expiresAt: null };
47
+ }
48
+ async waitForVerification(email, timeoutSec) {
49
+ const client = new ImapFlow({
50
+ host: this.config.host,
51
+ port: this.config.port ?? 993,
52
+ secure: (this.config.port ?? 993) === 993,
53
+ auth: { user: this.config.user, pass: this.config.pass },
54
+ logger: false,
55
+ });
56
+ const deadline = Date.now() + timeoutSec * 1000;
57
+ const since = new Date(Date.now() - 5 * 60 * 1000);
58
+ await client.connect();
59
+ try {
60
+ const lock = await client.getMailboxLock("INBOX");
61
+ try {
62
+ while (Date.now() < deadline) {
63
+ const uids = await client.search({ to: email, since }, { uid: true });
64
+ if (uids && uids.length) {
65
+ const uid = uids[uids.length - 1];
66
+ const dl = await client.download(String(uid), undefined, { uid: true });
67
+ const parsed = await simpleParser(dl.content);
68
+ const subject = parsed.subject ?? "";
69
+ const text = (parsed.text ?? "") + "\n" + (parsed.html || "");
70
+ const { code, links } = extractVerification(subject, text);
71
+ return {
72
+ found: true,
73
+ timedOut: false,
74
+ code,
75
+ from: parsed.from?.text,
76
+ subject,
77
+ links,
78
+ };
79
+ }
80
+ await new Promise((r) => setTimeout(r, 5000));
81
+ }
82
+ return { found: false, timedOut: true, code: null, links: [] };
83
+ }
84
+ finally {
85
+ lock.release();
86
+ }
87
+ }
88
+ finally {
89
+ await client.logout().catch(() => { });
90
+ }
91
+ }
92
+ }
93
+ /** Used by `sandgate connect-imap` to validate credentials before saving. */
94
+ export async function testImapConnection(config) {
95
+ const client = new ImapFlow({
96
+ host: config.host,
97
+ port: config.port ?? 993,
98
+ secure: (config.port ?? 993) === 993,
99
+ auth: { user: config.user, pass: config.pass },
100
+ logger: false,
101
+ });
102
+ await client.connect();
103
+ await client.logout().catch(() => { });
104
+ }
package/dist/index.js ADDED
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env node
2
+ import { stdin, stdout } from "node:process";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { read } from "read";
6
+ import { getQuota } from "./sandmail.js";
7
+ import { testImapConnection } from "./inbox.js";
8
+ import { auditPath } from "./paths.js";
9
+ import { vaultExists, loadVault, saveVault, } from "./vault.js";
10
+ import { loadConfig, saveConfig } from "./config.js";
11
+ import { normalizeSecret, generateCode } from "./totp.js";
12
+ import { TelegramApprover, discoverChatId } from "./telegram.js";
13
+ import { serve } from "./server.js";
14
+ import { sandgateDir } from "./paths.js";
15
+ const HELP = `sandgate — the human gateway for AI agents
16
+
17
+ Usage:
18
+ sandgate init Create the vault, connect Telegram & sandmail
19
+ sandgate add-totp <domain> <secret> Store a 2FA seed (base32 or otpauth:// URI)
20
+ sandgate policy <domain> <auto|approve|deny> Set the 2FA policy for a domain
21
+ sandgate connect-telegram <bot-token> Connect (or fix) the Telegram approval channel
22
+ sandgate relay [port] Run the approval relay (serves the phone PWA)
23
+ sandgate pair <relay-url> Pair your phone via the relay (E2EE, replaces Telegram)
24
+ sandgate connect-sandmail <api-key> Connect the sandmail inbox backend
25
+ sandgate connect-imap Connect your own IMAP mailbox instead (self-hosted)
26
+ sandgate test-approval Send a test approval to your phone
27
+ sandgate status Show what is configured and active
28
+ sandgate audit [n] Show the last n audit entries (default 20)
29
+ sandgate serve Run the MCP server (stdio)
30
+
31
+ The vault passphrase is read from SANDGATE_PASSPHRASE when running non-interactively
32
+ (which is how MCP clients launch \`sandgate serve\`).`;
33
+ /**
34
+ * Prompts that work in both worlds. Interactive TTY: the `read` package —
35
+ * the same battle-tested prompt npm itself uses for `npm login`, with
36
+ * asterisk masking for secrets. Piped/scripted stdin: the whole input is
37
+ * read upfront and answers are consumed line by line (readline drops lines
38
+ * that arrive between two questions, which silently killed piped `init`).
39
+ */
40
+ class Prompter {
41
+ queue = null;
42
+ async ensureReady() {
43
+ if (stdin.isTTY || this.queue)
44
+ return;
45
+ let data = "";
46
+ for await (const chunk of stdin)
47
+ data += chunk;
48
+ this.queue = data.split(/\r?\n/);
49
+ }
50
+ async ask(query, opts) {
51
+ await this.ensureReady();
52
+ if (this.queue) {
53
+ const value = (this.queue.shift() ?? "").trim();
54
+ stdout.write(query + (opts?.hidden ? "(hidden)" : value) + "\n");
55
+ return value;
56
+ }
57
+ const answer = await read({
58
+ prompt: query,
59
+ silent: opts?.hidden ?? false,
60
+ replace: opts?.hidden ? "*" : undefined,
61
+ });
62
+ return answer.trim();
63
+ }
64
+ close() {
65
+ // `read` cleans up after each prompt; nothing to release.
66
+ }
67
+ }
68
+ async function getPassphrase(prompter, confirm = false) {
69
+ const env = process.env.SANDGATE_PASSPHRASE;
70
+ if (env)
71
+ return env;
72
+ const pass = await prompter.ask("Vault passphrase: ", { hidden: true });
73
+ if (!pass) {
74
+ console.error("A passphrase is required.");
75
+ process.exit(1);
76
+ }
77
+ if (confirm) {
78
+ const again = await prompter.ask("Confirm passphrase: ", { hidden: true });
79
+ if (again !== pass) {
80
+ console.error("Passphrases do not match.");
81
+ process.exit(1);
82
+ }
83
+ }
84
+ return pass;
85
+ }
86
+ async function cmdInit() {
87
+ console.log(`sandgate init — files live in ${sandgateDir()}\n`);
88
+ if (vaultExists()) {
89
+ console.error("A vault already exists. Delete ~/.sandgate/vault.enc to start over.");
90
+ process.exit(1);
91
+ }
92
+ const prompter = new Prompter();
93
+ console.log("The vault passphrase is sandgate's master password: it encrypts your 2FA\n" +
94
+ "seeds and API keys (AES-256-GCM). Choose it well — it cannot be recovered.\n");
95
+ const pass = await getPassphrase(prompter, true);
96
+ const data = { totp: {} };
97
+ console.log("\nApproval channel (Telegram) — create a bot with @BotFather, then send it any message.");
98
+ const botToken = await prompter.ask("Bot token (empty to skip): ");
99
+ if (botToken) {
100
+ const chatId = await discoverChatId(botToken);
101
+ if (chatId) {
102
+ data.telegram = { botToken, chatId };
103
+ console.log(`Connected: chat ${chatId}.`);
104
+ }
105
+ else {
106
+ console.log("No message found — send your bot a message first, then run: sandgate connect-telegram <token>");
107
+ }
108
+ }
109
+ console.log("\nInbox backend (sandmail) — for create_identity / wait_for_verification.");
110
+ const apiKey = await prompter.ask("sandmail API key (empty to skip): ");
111
+ if (apiKey)
112
+ data.sandmail = { apiKey };
113
+ prompter.close();
114
+ saveVault(pass, data);
115
+ saveConfig(loadConfig());
116
+ console.log(`\nVault created. Next steps:\n` +
117
+ ` sandgate add-totp github.com <secret>\n` +
118
+ ` sandgate test-approval\n` +
119
+ `Then register the MCP server in your agent (see README).`);
120
+ }
121
+ async function cmdAddTotp(domain, secret) {
122
+ if (!domain || !secret) {
123
+ console.error("Usage: sandgate add-totp <domain> <secret>");
124
+ process.exit(1);
125
+ }
126
+ const prompter = new Prompter();
127
+ const pass = await getPassphrase(prompter);
128
+ prompter.close();
129
+ const data = loadVault(pass);
130
+ const key = domain.toLowerCase().replace(/^www\./, "");
131
+ const normalized = normalizeSecret(secret);
132
+ const { code } = generateCode(normalized); // validates the seed
133
+ data.totp[key] = { secret: normalized };
134
+ saveVault(pass, data);
135
+ console.log(`Stored 2FA seed for ${key}. Current code: ${code} (sanity check against your authenticator).`);
136
+ }
137
+ async function cmdPolicy(domain, policy) {
138
+ const valid = ["auto", "approve", "deny"];
139
+ if (!domain || !valid.includes(policy)) {
140
+ console.error("Usage: sandgate policy <domain> <auto|approve|deny>");
141
+ process.exit(1);
142
+ }
143
+ const config = loadConfig();
144
+ config.policies.totp[domain.toLowerCase()] = policy;
145
+ saveConfig(config);
146
+ console.log(`2FA policy for ${domain}: ${policy}`);
147
+ }
148
+ async function cmdConnectTelegram(botToken) {
149
+ if (!botToken) {
150
+ console.error("Usage: sandgate connect-telegram <bot-token>");
151
+ process.exit(1);
152
+ }
153
+ const prompter = new Prompter();
154
+ const pass = await getPassphrase(prompter);
155
+ prompter.close();
156
+ const data = loadVault(pass);
157
+ const chatId = await discoverChatId(botToken);
158
+ if (!chatId) {
159
+ console.error("No message found for this bot. Open Telegram, send your bot any message, then rerun this command.");
160
+ process.exit(1);
161
+ }
162
+ data.telegram = { botToken, chatId };
163
+ saveVault(pass, data);
164
+ console.log(`Telegram connected (chat ${chatId}). Try: sandgate test-approval`);
165
+ }
166
+ async function cmdConnectSandmail(apiKey) {
167
+ if (!apiKey) {
168
+ console.error("Usage: sandgate connect-sandmail <api-key>");
169
+ process.exit(1);
170
+ }
171
+ const prompter = new Prompter();
172
+ const pass = await getPassphrase(prompter);
173
+ prompter.close();
174
+ const data = loadVault(pass);
175
+ try {
176
+ const quota = await getQuota(apiKey);
177
+ data.sandmail = { apiKey };
178
+ saveVault(pass, data);
179
+ console.log(`sandmail connected (quota: ${quota.remaining}/${quota.limit} remaining). ` +
180
+ `Agents can now use create_identity and wait_for_verification.`);
181
+ }
182
+ catch (err) {
183
+ console.error(`Could not validate the sandmail key: ${err instanceof Error ? err.message : err}`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+ async function cmdConnectImap() {
188
+ const prompter = new Prompter();
189
+ const pass = await getPassphrase(prompter);
190
+ const data = loadVault(pass);
191
+ console.log("\nYour own IMAP mailbox as the inbox backend. Agent identities become\n" +
192
+ "plus-addressed aliases (you+sg1a2b@domain) — check your provider supports them.\n" +
193
+ "Use an app password, not your main account password, whenever available.\n");
194
+ const host = await prompter.ask("IMAP host (e.g. imap.fastmail.com): ");
195
+ const portRaw = await prompter.ask("Port [993]: ");
196
+ const user = await prompter.ask("User / email: ");
197
+ const imapPass = await prompter.ask("Password (app password): ", { hidden: true });
198
+ const baseEmail = await prompter.ask(`Alias base address [${user}]: `);
199
+ prompter.close();
200
+ const config = {
201
+ host: host.trim(),
202
+ port: portRaw ? parseInt(portRaw, 10) : 993,
203
+ user: user.trim(),
204
+ pass: imapPass,
205
+ baseEmail: baseEmail.trim() || undefined,
206
+ };
207
+ process.stdout.write("Testing the connection… ");
208
+ try {
209
+ await testImapConnection(config);
210
+ console.log("ok.");
211
+ }
212
+ catch (err) {
213
+ console.error(`failed: ${err instanceof Error ? err.message : err}`);
214
+ process.exit(1);
215
+ }
216
+ data.imap = config;
217
+ saveVault(pass, data);
218
+ const note = data.sandmail
219
+ ? " Note: sandmail is also configured and takes precedence; remove it from the vault to use IMAP."
220
+ : "";
221
+ console.log(`IMAP connected (${config.user}@${config.host}).${note}`);
222
+ }
223
+ async function cmdStatus() {
224
+ if (!vaultExists()) {
225
+ console.log("No vault. Run `sandgate init` to get started.");
226
+ return;
227
+ }
228
+ const prompter = new Prompter();
229
+ const pass = await getPassphrase(prompter);
230
+ prompter.close();
231
+ const data = loadVault(pass);
232
+ const config = loadConfig();
233
+ const domains = Object.keys(data.totp);
234
+ const auditCount = existsSync(auditPath())
235
+ ? readFileSync(auditPath(), "utf8").trim().split("\n").filter(Boolean).length
236
+ : 0;
237
+ const approval = data.pwa
238
+ ? `PWA via ${data.pwa.relayUrl} (Telegram ${data.telegram ? "fallback" : "not set"})`
239
+ : data.telegram
240
+ ? "Telegram"
241
+ : "none — run `sandgate pair <relay-url>` or `sandgate connect-telegram <token>`";
242
+ const inboxLine = data.sandmail
243
+ ? "sandmail" + (data.imap ? " (imap configured but sandmail takes precedence)" : "")
244
+ : data.imap
245
+ ? `imap (${data.imap.user}@${data.imap.host})`
246
+ : "none — run `sandgate connect-sandmail <key>` or `sandgate connect-imap`";
247
+ console.log(`sandgate status — ${sandgateDir()}\n`);
248
+ console.log(` approval channel ${approval}`);
249
+ console.log(` inbox backend ${inboxLine}`);
250
+ console.log(` 2FA seeds ${domains.length ? domains.join(", ") : "none — add with \`sandgate add-totp <domain> <secret>\`"}`);
251
+ console.log(` 2FA policy default ${config.policies.totpDefault}` +
252
+ (Object.keys(config.policies.totp).length
253
+ ? "; " +
254
+ Object.entries(config.policies.totp)
255
+ .map(([d, p]) => `${d}=${p}`)
256
+ .join(", ")
257
+ : ""));
258
+ console.log(` audit entries ${auditCount}`);
259
+ }
260
+ async function cmdAudit(countArg) {
261
+ const count = Math.max(1, parseInt(countArg ?? "20", 10) || 20);
262
+ if (!existsSync(auditPath())) {
263
+ console.log("No audit entries yet.");
264
+ return;
265
+ }
266
+ const lines = readFileSync(auditPath(), "utf8").trim().split("\n").slice(-count);
267
+ const icons = {
268
+ auto: "·",
269
+ approved: "✓",
270
+ denied: "✗",
271
+ timeout: "…",
272
+ error: "!",
273
+ };
274
+ for (const line of lines) {
275
+ try {
276
+ const e = JSON.parse(line);
277
+ const when = e.ts.replace("T", " ").slice(0, 19);
278
+ const what = e.domain ?? e.action ?? e.detail ?? "";
279
+ console.log(`${when} ${icons[e.decision] ?? "·"} ${String(e.decision).padEnd(8)} ${e.tool.padEnd(22)} ${what}`);
280
+ }
281
+ catch {
282
+ // skip malformed lines rather than crash the report
283
+ }
284
+ }
285
+ }
286
+ async function cmdRelay(portArg) {
287
+ const port = portArg ? parseInt(portArg, 10) : 8787;
288
+ const { startRelay } = await import("./relay/server.js");
289
+ const relay = await startRelay({
290
+ port,
291
+ stateDir: join(sandgateDir(), "relay"),
292
+ });
293
+ console.log(`sandgate relay listening on http://localhost:${relay.port}\n` +
294
+ `Behind TLS (required for phone push), pair with: sandgate pair https://your-relay-host`);
295
+ }
296
+ async function cmdPair(relayUrl) {
297
+ if (!relayUrl) {
298
+ console.error("Usage: sandgate pair <relay-url>\n" +
299
+ "Run `sandgate relay` first (behind TLS for a real phone; http://localhost:8787 works for a desktop browser test).");
300
+ process.exit(1);
301
+ }
302
+ const prompter = new Prompter();
303
+ const pass = await getPassphrase(prompter);
304
+ prompter.close();
305
+ const data = loadVault(pass);
306
+ const { newPairing } = await import("./pwacrypto.js");
307
+ const pairing = newPairing();
308
+ const base = relayUrl.replace(/\/$/, "");
309
+ const pairLink = `${base}/#p=${pairing.pairId}&s=${pairing.secret}`;
310
+ data.pwa = { relayUrl: base, pairId: pairing.pairId, secret: pairing.secret };
311
+ saveVault(pass, data);
312
+ const qrcode = (await import("qrcode-terminal")).default;
313
+ console.log("\nOpen this link on your phone (the secret is in the URL fragment — it never reaches the relay):\n");
314
+ console.log(` ${pairLink}\n`);
315
+ qrcode.generate(pairLink, { small: true });
316
+ console.log("\nWaiting for the phone to subscribe (2 min)…");
317
+ const deadline = Date.now() + 120_000;
318
+ while (Date.now() < deadline) {
319
+ try {
320
+ const res = await fetch(`${base}/api/pair-status?pairId=${encodeURIComponent(pairing.pairId)}`);
321
+ const status = (await res.json());
322
+ if (status.subscribed) {
323
+ console.log("Paired! The PWA now takes over approvals (Telegram becomes the fallback). Try: sandgate test-approval");
324
+ return;
325
+ }
326
+ }
327
+ catch {
328
+ // relay not reachable yet; keep trying
329
+ }
330
+ await new Promise((r) => setTimeout(r, 3000));
331
+ }
332
+ console.log("No subscription yet — the pairing is saved anyway. Open the link on the phone, then check with: sandgate test-approval");
333
+ }
334
+ async function cmdTestApproval() {
335
+ const prompter = new Prompter();
336
+ const pass = await getPassphrase(prompter);
337
+ prompter.close();
338
+ const data = loadVault(pass);
339
+ let approver;
340
+ if (data.pwa) {
341
+ const { PwaApprover } = await import("./pwa-approver.js");
342
+ approver = new PwaApprover(data.pwa);
343
+ console.log("Sending test approval to the paired PWA (60s timeout)…");
344
+ }
345
+ else if (data.telegram) {
346
+ approver = new TelegramApprover(data.telegram.botToken, data.telegram.chatId);
347
+ console.log("Sending test approval to Telegram (60s timeout)…");
348
+ }
349
+ else {
350
+ console.error("No approval channel. Run `sandgate connect-telegram <bot-token>` or `sandgate pair <relay-url>`.");
351
+ process.exit(1);
352
+ }
353
+ const result = await approver.request({
354
+ title: "Test from sandgate",
355
+ body: "Tap Approve to confirm your approval channel works.",
356
+ timeoutSec: 60,
357
+ });
358
+ console.log(`Result: ${result.decision}`);
359
+ }
360
+ async function main() {
361
+ const [command, ...args] = process.argv.slice(2);
362
+ switch (command) {
363
+ case "init":
364
+ return cmdInit();
365
+ case "add-totp":
366
+ return cmdAddTotp(args[0], args[1]);
367
+ case "policy":
368
+ return cmdPolicy(args[0], args[1]);
369
+ case "connect-telegram":
370
+ return cmdConnectTelegram(args[0]);
371
+ case "connect-sandmail":
372
+ return cmdConnectSandmail(args[0]);
373
+ case "connect-imap":
374
+ return cmdConnectImap();
375
+ case "test-approval":
376
+ return cmdTestApproval();
377
+ case "relay":
378
+ return cmdRelay(args[0]);
379
+ case "pair":
380
+ return cmdPair(args[0]);
381
+ case "status":
382
+ return cmdStatus();
383
+ case "audit":
384
+ return cmdAudit(args[0]);
385
+ case undefined:
386
+ case "serve": {
387
+ const pass = process.env.SANDGATE_PASSPHRASE;
388
+ if (!pass) {
389
+ console.error("SANDGATE_PASSPHRASE is not set. MCP clients launch sandgate non-interactively;\n" +
390
+ 'add it to the server config, e.g. {"env": {"SANDGATE_PASSPHRASE": "..."}}');
391
+ process.exit(1);
392
+ }
393
+ return serve(pass);
394
+ }
395
+ case "help":
396
+ case "--help":
397
+ case "-h":
398
+ console.log(HELP);
399
+ return;
400
+ default:
401
+ console.error(`Unknown command: ${command}\n\n${HELP}`);
402
+ process.exit(1);
403
+ }
404
+ }
405
+ main().catch((err) => {
406
+ console.error(err instanceof Error ? err.message : err);
407
+ process.exit(1);
408
+ });