granttap-mcp 0.6.4 → 0.6.5

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
@@ -109,7 +109,7 @@ Existing beta state under `~/.nodvox/` is migrated automatically.
109
109
  | Approval hook | `PermissionRequest` when Codex hooks are enabled | `PreToolUse` |
110
110
  | Resume an existing task | Yes, through the local Codex CLI | Yes, through the local Claude CLI |
111
111
  | Start a new persistent task | Yes | Yes |
112
- | Up to five attachments | Images through Codex image inputs; documents as local paths | Local image/document paths in the turn |
112
+ | Up to five attachments within one encrypted-frame budget | Images through Codex image inputs; documents as local paths | Local image/document paths in the turn |
113
113
  | Change filesystem access from iPhone | Read-only, workspace, or full for the next GrantTap turn | Not exposed; the existing Claude policy remains authoritative |
114
114
  | Disable MCP per task | Enforced for later GrantTap-delivered turns | Enforced for later GrantTap-delivered turns |
115
115
  | Usage and context | Reported when present in local task logs | Reported when present in local task logs |
@@ -152,6 +152,14 @@ The default answer timeout is three minutes. Override it with
152
152
  Durable Objects, and APNs path.
153
153
  - APNs carries only a content-neutral wake. It contains no title, prompt,
154
154
  command, path, task kind, or response.
155
+ - Cloudflare accepts at most a 32 MiB WebSocket frame. Because task messages are
156
+ sealed and base64-encoded twice, attachments share a 16,000,000-character
157
+ base64 budget (about 12 MB raw total); five small files fit, five 6 MB files do not.
158
+
159
+ The in-chat QR is marked user-only for MCP hosts, but that annotation is not a
160
+ cryptographic separation from the model provider. Use CLI `connect` when the
161
+ model provider itself is part of your threat model; Cloudflare still receives
162
+ only the mailbox id and ciphertext in either flow.
155
163
 
156
164
  The relay can still observe operational metadata: opaque room/mailbox IDs, IP
157
165
  addresses, timing, ciphertext sizes, and APNs device token/environment. A
package/SECURITY.md CHANGED
@@ -18,8 +18,8 @@ to an issue, or pasted into logs. The package avoids printing device secret
18
18
  keys. Existing configuration files are backed up before hook installation.
19
19
 
20
20
  Current pairings contain three independent secret classes: NaCl endpoint keys,
21
- random per-task keys, and a random `pushAuth` room credential that only
22
- authorizes APNs device-token registration at the relay. None may be logged.
21
+ random per-task keys, and a random `pushAuth` room credential that authorizes
22
+ the room WebSocket and APNs device-token registration at the relay. None may be logged.
23
23
  The relay receives only a hash of `pushAuth` and never receives an endpoint's
24
24
  secret encryption key.
25
25
 
@@ -31,6 +31,11 @@ secret encryption key.
31
31
  separate random 256-bit transfer key. Only the mailbox id is used in the HTTP
32
32
  path. The key stays in the QR/manual token, so a relay operator or a Durable
33
33
  Object database dump cannot open the parked pairing blob.
34
+ - The terminal `connect` flow keeps that transfer key outside model context.
35
+ The MCP `connect` tool deliberately returns a QR in chat for convenience and
36
+ marks it user-only, but MCP audience annotations are not a cryptographic
37
+ boundary: depending on the host, the chat/model provider may receive tool
38
+ images. Use terminal pairing when the model provider is in your threat model.
34
39
  - Every attached Codex or Claude Code task receives a separate random 256-bit
35
40
  task key. Task messages, attachments, visible activity, access/MCP changes,
36
41
  compaction results, and task-bound approvals use this additional authenticated
@@ -43,6 +48,8 @@ secret encryption key.
43
48
  transport, network, Cloudflare, Durable Objects, and APNs.
44
49
  - APNs is only a content-neutral wake. It contains no task kind, request id,
45
50
  delivery id, title, prompt, command, path, or response.
51
+ It is a best-effort silent background notification; after waking, iOS pulls
52
+ and decrypts the queue and creates the single actionable local notification.
46
53
  - Scheduler-planner turns and their structured drafts cross Cloudflare only as
47
54
  authenticated device-to-device ciphertext. The selected CLI runs locally in
48
55
  ephemeral read-only/plan mode; it cannot silently turn planning into a
@@ -65,7 +72,10 @@ different task's key—not against an already authorized endpoint.
65
72
 
66
73
  The delivery ledger and scheduler history are stored only on the paired Mac.
67
74
  They contain random message ids and local task metadata and are bounded and
68
- expired; do not attach real copies to bug reports.
75
+ expired; do not attach real copies to bug reports. Delivery ACK is emitted only
76
+ after a consumer accepts the decrypted payload. A processing lease recovers
77
+ after a crash; agent execution is at-least-once across that rare crash window,
78
+ because third-party Codex/Claude CLIs do not expose a transactional idempotency key.
69
79
 
70
80
  Chat history and capability metadata follow the same cryptographic boundary.
71
81
  The bridge reads a bounded set of local Codex/Claude logs, emits only chat
@@ -28,7 +28,16 @@ async function main(): Promise<void> {
28
28
  try {
29
29
  input = JSON.parse(raw) as HookInput;
30
30
  } catch {
31
- // Unparseable input -> deny, but don't crash the agent.
31
+ process.stdout.write(
32
+ JSON.stringify({
33
+ hookSpecificOutput: {
34
+ hookEventName: "PreToolUse",
35
+ permissionDecision: "deny",
36
+ permissionDecisionReason: "GrantTap received invalid hook JSON",
37
+ },
38
+ }),
39
+ );
40
+ return;
32
41
  }
33
42
 
34
43
  // Gating paused, or this session is exempt → abstain (empty output = Claude
@@ -37,7 +37,8 @@ async function main(): Promise<void> {
37
37
  try {
38
38
  input = JSON.parse(raw) as HookInput;
39
39
  } catch {
40
- // fall through: unparseable -> deny
40
+ process.stdout.write(denyOutput("GrantTap received invalid hook JSON"));
41
+ return;
41
42
  }
42
43
 
43
44
  // Gating paused or this session exempt → stay silent (Codex uses its own flow).
@@ -166,7 +166,9 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
166
166
  } | undefined> {
167
167
  const dataMatch = src.match(/^data:image\/(png|jpe?g);base64,([a-z0-9+/=\s]+)$/i);
168
168
  if (dataMatch) {
169
- const bytes = Buffer.from(dataMatch[2]!, "base64");
169
+ const encoded = dataMatch[2]!.replace(/\s/g, "");
170
+ if (encoded.length > Math.ceil(MAX_MCP_ICON_BYTES * 4 / 3) + 4) return undefined;
171
+ const bytes = Buffer.from(encoded, "base64");
170
172
  const mimeType = imageMime(bytes);
171
173
  if (!mimeType || bytes.length > MAX_MCP_ICON_BYTES) return undefined;
172
174
  return { src: `data:${mimeType};base64,${bytes.toString("base64")}`, mimeType };
@@ -197,7 +199,8 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
197
199
  if (!response.ok) return undefined;
198
200
  const length = Number(response.headers.get("content-length") ?? 0);
199
201
  if (Number.isFinite(length) && length > MAX_MCP_ICON_BYTES) return undefined;
200
- const bytes = Buffer.from(await response.arrayBuffer());
202
+ const bytes = await readResponseBodyLimited(response, MAX_MCP_ICON_BYTES);
203
+ if (!bytes) return undefined;
201
204
  const mimeType = imageMime(bytes);
202
205
  if (!mimeType || bytes.length > MAX_MCP_ICON_BYTES) return undefined;
203
206
  return { src: `data:${mimeType};base64,${bytes.toString("base64")}`, mimeType };
@@ -208,6 +211,24 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
208
211
  return undefined;
209
212
  }
210
213
 
214
+ async function readResponseBodyLimited(response: Response, maxBytes: number): Promise<Buffer | undefined> {
215
+ if (!response.body) return undefined;
216
+ const reader = response.body.getReader();
217
+ const chunks: Uint8Array[] = [];
218
+ let total = 0;
219
+ for (;;) {
220
+ const { done, value } = await reader.read();
221
+ if (done) break;
222
+ total += value.byteLength;
223
+ if (total > maxBytes) {
224
+ await reader.cancel().catch(() => {});
225
+ return undefined;
226
+ }
227
+ chunks.push(value);
228
+ }
229
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
230
+ }
231
+
211
232
  async function normalizedIcons(
212
233
  info: Implementation,
213
234
  descriptor: McpDescriptor,
@@ -272,7 +293,7 @@ async function normalizeServerMetadata(
272
293
  async function probeMetadata(descriptor: McpDescriptor): Promise<ServerMetadata | undefined> {
273
294
  const config = descriptor.transport;
274
295
  if (!config || !descriptor.configuredEnabled) return undefined;
275
- const client = new Client({ name: "granttap-metadata", version: "0.6.4" });
296
+ const client = new Client({ name: "granttap-metadata", version: "0.6.5" });
276
297
  try {
277
298
  const type = typeof config.type === "string" ? config.type : undefined;
278
299
  if ((type === "streamable_http" || type === "http" || type === "sse") &&
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
12
  import { hostname } from "node:os";
13
13
  import { generateKeyPair, randomId } from "../../../packages/core/crypto";
14
14
  import type { PeerConfig } from "../../../packages/core/relay-client";
@@ -88,7 +88,8 @@ export function loadRuntimeConfig(): RuntimeConfig {
88
88
 
89
89
  export function saveRuntimeConfig(cfg: RuntimeConfig): void {
90
90
  mkdirSync(configDir(), { recursive: true });
91
- writeFileSync(runtimeConfigPath(), JSON.stringify(cfg, null, 2) + "\n");
91
+ writeFileSync(runtimeConfigPath(), JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
92
+ chmodSync(runtimeConfigPath(), 0o600);
92
93
  }
93
94
 
94
95
  /** True when the hook should stay out of the way for this session. */
@@ -105,6 +106,8 @@ export function phonePairingPath(): string {
105
106
  export function saveConfig(path: string, cfg: PeerConfig): void {
106
107
  mkdirSync(configDir(), { recursive: true });
107
108
  writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
109
+ // `mode` only applies when creating a file; repair permissive legacy files too.
110
+ chmodSync(path, 0o600);
108
111
  }
109
112
 
110
113
  export function loadConfig(path: string): PeerConfig {
@@ -123,6 +126,32 @@ const unb64url = (s: string): string => {
123
126
  return t + "=".repeat((4 - (t.length % 4)) % 4);
124
127
  };
125
128
 
129
+ /** Canonicalize relay endpoints and reject schemes that can expose pairing data. */
130
+ export function normalizeRelayUrl(value: string): string {
131
+ let url: URL;
132
+ try {
133
+ url = new URL(value);
134
+ } catch {
135
+ throw new Error("relay URL is invalid");
136
+ }
137
+ if (url.protocol !== "wss:" && url.protocol !== "ws:") {
138
+ throw new Error("relay URL must use wss:// (ws:// is allowed for local development)");
139
+ }
140
+ const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost")
141
+ || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
142
+ if (url.protocol === "ws:" && !loopback) {
143
+ throw new Error("unencrypted ws:// is allowed only for a loopback development relay");
144
+ }
145
+ if (url.username || url.password || url.hash || url.search) {
146
+ throw new Error("relay URL must not contain credentials, a query, or a fragment");
147
+ }
148
+ return url.toString().replace(/\/$/, "");
149
+ }
150
+
151
+ function validPairingKey(value: string): boolean {
152
+ return /^[A-Za-z0-9_-]{43,44}$/.test(value) && Buffer.from(unb64url(value), "base64").length === 32;
153
+ }
154
+
126
155
  export function pairingUri(cfg: PeerConfig): string {
127
156
  const q = new URLSearchParams({
128
157
  v: "1",
@@ -148,9 +177,17 @@ export function parsePairingUri(uri: string): PeerConfig | null {
148
177
  return null;
149
178
  }
150
179
  const get = (k: string) => q.get(k) ?? "";
151
- if (!get("u") || !get("r") || !get("s") || !get("p")) return null;
180
+ if (get("v") !== "1" || !/^[a-f0-9]{16,64}$/.test(get("r"))) return null;
181
+ if (!["s", "p", "k"].every((key) => validPairingKey(get(key)))) return null;
182
+ if (get("i").length > 180 || (get("a") && !/^[a-f0-9]{64}$/.test(get("a")))) return null;
183
+ let relayUrl: string;
184
+ try {
185
+ relayUrl = normalizeRelayUrl(get("u"));
186
+ } catch {
187
+ return null;
188
+ }
152
189
  return {
153
- relayUrl: get("u"),
190
+ relayUrl,
154
191
  room: get("r"),
155
192
  role: "phone",
156
193
  deviceName: "phone",
@@ -164,9 +201,11 @@ export function parsePairingUri(uri: string): PeerConfig | null {
164
201
 
165
202
  /** Create a fresh machine<->phone pairing. */
166
203
  export function createPairing(relayUrl: string): { machineCfg: PeerConfig; phoneCfg: PeerConfig } {
204
+ relayUrl = normalizeRelayUrl(relayUrl);
167
205
  const machine = generateKeyPair();
168
206
  const phone = generateKeyPair();
169
- const room = randomId(8);
207
+ // 128 bits keeps the relay's TOFU room rendezvous unguessable in practice.
208
+ const room = randomId(16);
170
209
  const pushAuth = randomId(32);
171
210
 
172
211
  const machineCfg: PeerConfig = {
@@ -1,39 +1,87 @@
1
1
  /** Persistent idempotency ledger for phone messages and their delivery receipts. */
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { configDir } from "./config";
5
5
 
6
6
  const LIMIT = 500;
7
7
  const MAX_AGE_MS = 7 * 24 * 60 * 60_000;
8
+ const PROCESSING_LEASE_MS = 5 * 60_000;
9
+
10
+ type DeliveryRecord = {
11
+ state: "processing" | "completed";
12
+ updatedAt: number;
13
+ };
14
+
15
+ export type DeliveryStart = "started" | "processing" | "completed";
8
16
 
9
17
  export function deliveryLedgerPath(): string {
10
18
  return join(configDir(), "delivery-ledger.json");
11
19
  }
12
20
 
13
- function load(): Record<string, number> {
21
+ function load(): Record<string, DeliveryRecord> {
14
22
  try {
15
23
  const raw = JSON.parse(readFileSync(deliveryLedgerPath(), "utf8"));
16
24
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
17
- return Object.fromEntries(
18
- Object.entries(raw).filter((entry): entry is [string, number] =>
19
- typeof entry[0] === "string" && typeof entry[1] === "number"),
20
- );
25
+ const records: Record<string, DeliveryRecord> = {};
26
+ for (const [id, value] of Object.entries(raw)) {
27
+ // Migrate the original `{ id: acceptedAt }` ledger as completed work.
28
+ if (typeof value === "number") records[id] = { state: "completed", updatedAt: value };
29
+ else if (value && typeof value === "object"
30
+ && ((value as DeliveryRecord).state === "processing"
31
+ || (value as DeliveryRecord).state === "completed")
32
+ && typeof (value as DeliveryRecord).updatedAt === "number") {
33
+ records[id] = value as DeliveryRecord;
34
+ }
35
+ }
36
+ return records;
21
37
  } catch {
22
38
  return {};
23
39
  }
24
40
  }
25
41
 
26
42
  export function hasAcceptedDelivery(messageId: string, now = Date.now()): boolean {
27
- const acceptedAt = load()[messageId];
28
- return acceptedAt != null && acceptedAt >= now - MAX_AGE_MS;
43
+ const record = load()[messageId];
44
+ return record?.state === "completed" && record.updatedAt >= now - MAX_AGE_MS;
29
45
  }
30
46
 
31
- export function rememberAcceptedDelivery(messageId: string, now = Date.now()): void {
47
+ function save(records: Record<string, DeliveryRecord>, now: number): void {
32
48
  const cutoff = now - MAX_AGE_MS;
33
- const entries = Object.entries(load())
34
- .filter(([, acceptedAt]) => acceptedAt >= cutoff && acceptedAt <= now)
35
- .sort((a, b) => b[1] - a[1]);
36
- const next = Object.fromEntries([[messageId, now], ...entries.filter(([id]) => id !== messageId)].slice(0, LIMIT));
49
+ const next = Object.fromEntries(Object.entries(records)
50
+ .filter(([, record]) => record.updatedAt >= cutoff && record.updatedAt <= now)
51
+ .sort((a, b) => b[1].updatedAt - a[1].updatedAt)
52
+ .slice(0, LIMIT));
37
53
  mkdirSync(configDir(), { recursive: true });
38
54
  writeFileSync(deliveryLedgerPath(), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
55
+ chmodSync(deliveryLedgerPath(), 0o600);
56
+ }
57
+
58
+ /** Acquire a durable processing lease before invoking an agent. */
59
+ export function beginDelivery(messageId: string, now = Date.now()): DeliveryStart {
60
+ const records = load();
61
+ const current = records[messageId];
62
+ if (current?.state === "completed" && current.updatedAt >= now - MAX_AGE_MS) return "completed";
63
+ if (current?.state === "processing" && current.updatedAt >= now - PROCESSING_LEASE_MS) {
64
+ return "processing";
65
+ }
66
+ records[messageId] = { state: "processing", updatedAt: now };
67
+ save(records, now);
68
+ return "started";
69
+ }
70
+
71
+ export function completeDelivery(messageId: string, now = Date.now()): void {
72
+ const records = load();
73
+ records[messageId] = { state: "completed", updatedAt: now };
74
+ save(records, now);
75
+ }
76
+
77
+ /** Release a failed attempt so a relay retry can safely try again. */
78
+ export function abandonDelivery(messageId: string, now = Date.now()): void {
79
+ const records = load();
80
+ delete records[messageId];
81
+ save(records, now);
82
+ }
83
+
84
+ /** @deprecated Compatibility wrapper for callers/tests from the old ledger. */
85
+ export function rememberAcceptedDelivery(messageId: string, now = Date.now()): void {
86
+ completeDelivery(messageId, now);
39
87
  }
@@ -20,7 +20,7 @@ import { configDir, loadRuntimeConfig, saveRuntimeConfig } from "./config";
20
20
  import { mcpServersForSession, workspaceSkills } from "./capabilities";
21
21
  import { compactCodexSession } from "./codex-control";
22
22
  import { createClaudeSession, createCodexSession, deliverToSession } from "./reply";
23
- import { hasAcceptedDelivery, rememberAcceptedDelivery } from "./delivery";
23
+ import { abandonDelivery, beginDelivery, completeDelivery } from "./delivery";
24
24
  import { inspectAgentIntegrations } from "./install";
25
25
  import { primeSessionKeys, sendSessionPayload } from "./session-keys";
26
26
  import {
@@ -132,47 +132,72 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
132
132
  }
133
133
  };
134
134
 
135
- const off = client.onMessage((payload) => {
135
+ const off = client.onMessage(async (payload) => {
136
136
  // Codex may start one MCP server per open task. Exactly one instance owns
137
137
  // phone routing, so a single phone message can never create duplicate tasks.
138
- if (!leadership.acquire()) return;
138
+ if (!leadership.acquire()) return false;
139
139
  if (payload.type === "user.message") {
140
+ // Correlated replies belong to the MCP `ask` waiter in one process.
141
+ if (payload.requestId) return false;
142
+ let deliveryStarted = false;
140
143
  if (payload.messageId) {
141
- const duplicate = hasAcceptedDelivery(payload.messageId);
142
- if (!duplicate) rememberAcceptedDelivery(payload.messageId);
143
- void sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
144
- if (duplicate) return;
144
+ const state = beginDelivery(payload.messageId);
145
+ if (state === "completed") {
146
+ await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
147
+ return true;
148
+ }
149
+ if (state === "processing") return false;
150
+ deliveryStarted = true;
145
151
  }
146
- // Replies correlated with an MCP `ask` are consumed by that tool call.
147
- if (!payload.requestId) {
148
- void handleUserMessage(client, payload).finally(() => publish().catch(() => {}));
152
+ try {
153
+ await handleUserMessage(client, payload);
154
+ if (payload.messageId && deliveryStarted) {
155
+ completeDelivery(payload.messageId);
156
+ await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
157
+ }
158
+ void publish().catch(() => {});
159
+ return true;
160
+ } catch {
161
+ if (payload.messageId && deliveryStarted) abandonDelivery(payload.messageId);
162
+ return false;
149
163
  }
150
164
  } else if (payload.type === "config.set") {
151
165
  handleConfigSet(payload);
152
166
  void publish().catch(() => {});
167
+ return true;
153
168
  } else if (payload.type === "session.subscribe") {
154
169
  handleSubscription(subscriptions, payload);
155
170
  void publish().catch(() => {});
171
+ return true;
156
172
  } else if (payload.type === "session.access.set") {
157
173
  handleAccessSet(payload);
158
174
  void publish().catch(() => {});
175
+ return true;
159
176
  } else if (payload.type === "session.mcp.set") {
160
177
  handleMcpSet(payload);
161
178
  void publish().catch(() => {});
179
+ return true;
162
180
  } else if (payload.type === "session.compact") {
163
- void handleCompact(client, payload).finally(() => publish().catch(() => {}));
181
+ await handleCompact(client, payload);
182
+ void publish().catch(() => {});
183
+ return true;
164
184
  } else if (payload.type === "schedule.set") {
165
185
  handleScheduleSet(payload);
166
186
  void publish().catch(() => {});
187
+ return true;
167
188
  } else if (payload.type === "schedule.delete") {
168
189
  handleScheduleDelete(payload);
169
190
  void publish().catch(() => {});
191
+ return true;
170
192
  } else if (payload.type === "schedule.run") {
171
193
  handleScheduleRun(payload);
172
194
  void publish().catch(() => {});
195
+ return true;
173
196
  } else if (payload.type === "schedule.plan.request") {
174
- void handleSchedulePlan(client, payload);
197
+ await handleSchedulePlan(client, payload);
198
+ return true;
175
199
  }
200
+ return false;
176
201
  });
177
202
 
178
203
  const timer = setInterval(() => void publish().catch(() => {}), INTERVAL_MS);
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  createPairing,
10
10
  machineConfigPath,
11
+ normalizeRelayUrl,
11
12
  phonePairingPath,
12
13
  saveConfig,
13
14
  } from "./config";
@@ -30,7 +31,9 @@ export type OneTimePairing = {
30
31
  };
31
32
 
32
33
  export function relayHttpBase(relayUrl: string): string {
33
- return relayUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/$/, "");
34
+ const url = new URL(normalizeRelayUrl(relayUrl));
35
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
36
+ return url.toString().replace(/\/$/, "");
34
37
  }
35
38
 
36
39
  /**
@@ -66,6 +69,7 @@ export async function createOneTimePairing(
66
69
  headers: { "content-type": "application/json" },
67
70
  body: JSON.stringify(sealed),
68
71
  signal: AbortSignal.timeout(10_000),
72
+ redirect: "error",
69
73
  });
70
74
  } catch (error) {
71
75
  throw new Error(
@@ -1,6 +1,6 @@
1
1
  /** Terminal-free local schedules shared by Codex and Claude Code. */
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import {
@@ -42,6 +42,7 @@ export function scheduleHistorySnapshot(): ScheduleRunRecord[] {
42
42
  function saveHistory(history: ScheduleRunRecord[]): void {
43
43
  mkdirSync(configDir(), { recursive: true });
44
44
  writeFileSync(scheduleHistoryPath(), `${JSON.stringify(history.slice(0, 200), null, 2)}\n`, { mode: 0o600 });
45
+ chmodSync(scheduleHistoryPath(), 0o600);
45
46
  }
46
47
 
47
48
  export function loadSchedules(): ScheduledTask[] {
@@ -60,6 +61,7 @@ export function loadSchedules(): ScheduledTask[] {
60
61
  function saveSchedules(tasks: ScheduledTask[]): void {
61
62
  mkdirSync(configDir(), { recursive: true });
62
63
  writeFileSync(schedulesPath(), `${JSON.stringify(tasks, null, 2)}\n`, { mode: 0o600 });
64
+ chmodSync(schedulesPath(), 0o600);
63
65
  }
64
66
 
65
67
  export function scheduledSnapshot(now = Date.now()): ScheduledTask[] {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { generateTransferKey } from "../../../packages/core/crypto";
4
4
  import { RelayClient, type SendOptions } from "../../../packages/core/relay-client";
@@ -25,6 +25,7 @@ function load(): SessionKeys {
25
25
  function save(keys: SessionKeys): void {
26
26
  mkdirSync(configDir(), { recursive: true });
27
27
  writeFileSync(sessionKeysPath(), `${JSON.stringify(keys, null, 2)}\n`, { mode: 0o600 });
28
+ chmodSync(sessionKeysPath(), 0o600);
28
29
  }
29
30
 
30
31
  export function sessionKey(sessionId: string): string {
@@ -19,7 +19,7 @@ import { z } from "zod";
19
19
  import QRCode from "qrcode";
20
20
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
21
21
  import { RelayClient } from "../../../packages/core/relay-client";
22
- import { loadConfig, machineConfigPath } from "../../bridge/src/config";
22
+ import { loadConfig, machineConfigPath, normalizeRelayUrl } from "../../bridge/src/config";
23
23
  import { installClaudeHook, installCodexHook, installMonitorHelper } from "../../bridge/src/install";
24
24
  import { randomId } from "../../../packages/core/crypto";
25
25
  import type { Payload } from "../../../packages/protocol/schema";
@@ -67,7 +67,7 @@ async function main(): Promise<void> {
67
67
  const server = new McpServer({
68
68
  name: "granttap",
69
69
  title: "GrantTap",
70
- version: "0.6.4",
70
+ version: "0.6.5",
71
71
  websiteUrl: "https://granttap.com",
72
72
  icons: [
73
73
  {
@@ -84,7 +84,13 @@ async function main(): Promise<void> {
84
84
  {
85
85
  relayUrl: z
86
86
  .string()
87
+ .max(2_048)
87
88
  .url()
89
+ .refine((value) => {
90
+ try { normalizeRelayUrl(value); return true; } catch { return false; }
91
+ }, {
92
+ message: "Relay URL must use wss:// (or loopback ws:// for local development)",
93
+ })
88
94
  .optional()
89
95
  .describe("Optional wss:// relay URL. Omit to use the GrantTap production relay."),
90
96
  },
@@ -107,13 +113,18 @@ async function main(): Promise<void> {
107
113
  type: "text",
108
114
  text: [
109
115
  "Scan this QR with GrantTap on iPhone to pair this computer.",
110
- `Manual secure token: ${pairing.manualToken}`,
111
116
  `Relay: ${pairing.httpBase}`,
112
117
  `The encrypted mailbox is single-use and expires after ${PAIRING_CODE_TTL_MINUTES} minutes.`,
113
- "The relay receives only a random mailbox id and ciphertext; the independent 256-bit transfer key stays in this QR/token.",
118
+ "The relay receives only a random mailbox id and ciphertext; the independent 256-bit transfer key stays in this user-only QR.",
114
119
  ].join("\n"),
120
+ annotations: { audience: ["user"] },
121
+ },
122
+ {
123
+ type: "image",
124
+ data: png.toString("base64"),
125
+ mimeType: "image/png",
126
+ annotations: { audience: ["user"] },
115
127
  },
116
- { type: "image", data: png.toString("base64"), mimeType: "image/png" },
117
128
  ],
118
129
  };
119
130
  } catch (error) {
@@ -133,7 +144,7 @@ async function main(): Promise<void> {
133
144
  server.tool(
134
145
  "notify",
135
146
  "Push a short status/message to the user's phone. Fire-and-forget — use it to keep them informed without blocking.",
136
- { message: z.string().describe("Text to show on the phone") },
147
+ { message: z.string().min(1).max(8_000).describe("Text to show on the phone") },
137
148
  async ({ message }) => {
138
149
  const c = await relay();
139
150
  if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
@@ -149,7 +160,7 @@ async function main(): Promise<void> {
149
160
  server.tool(
150
161
  "ask_yes_no",
151
162
  "Ask the user a yes/no question on their phone/watch and wait for the tap. Returns 'yes' or 'no'.",
152
- { question: z.string().describe("A question answerable with yes/no") },
163
+ { question: z.string().min(1).max(8_000).describe("A question answerable with yes/no") },
153
164
  async ({ question }) => {
154
165
  const c = await relay();
155
166
  if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
@@ -183,7 +194,7 @@ async function main(): Promise<void> {
183
194
  server.tool(
184
195
  "ask",
185
196
  "Ask the user an open question on their phone/watch and wait for their spoken or typed reply. Returns their answer text.",
186
- { question: z.string().describe("The question to ask") },
197
+ { question: z.string().min(1).max(8_000).describe("The question to ask") },
187
198
  async ({ question }) => {
188
199
  const c = await relay();
189
200
  if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
@@ -32,7 +32,8 @@ if (command === "mcp") {
32
32
  process.exit(1);
33
33
  }
34
34
 
35
- const child = spawn(process.execPath, ["--import", "tsx", entry], {
35
+ const forwardedArgs = command === "connect" ? process.argv.slice(3) : [];
36
+ const child = spawn(process.execPath, ["--import", "tsx", entry, ...forwardedArgs], {
36
37
  stdio: "inherit",
37
38
  env: process.env,
38
39
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Control Codex and Claude Code from iPhone and Apple Watch with zero-knowledge E2EE approvals, replies, and task sync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,9 @@
22
22
  "prepublishOnly": "npm test && npm run typecheck"
23
23
  },
24
24
  "dependencies": {
25
- "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@modelcontextprotocol/sdk": "^1.30.0",
26
26
  "qrcode": "^1.5.4",
27
- "tsx": "^4.19.2",
27
+ "tsx": "^4.23.4",
28
28
  "tweetnacl": "^1.0.3",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.24.1"
@@ -10,7 +10,7 @@
10
10
  * the Cloudflare Durable Object deployment.
11
11
  */
12
12
  import WebSocket from "ws";
13
- import { randomUUID } from "node:crypto";
13
+ import { createHash, randomUUID } from "node:crypto";
14
14
  import { Envelope, PROTOCOL_VERSION, Payload, type Role } from "../protocol/schema";
15
15
  import { open, openWithTransferKey, seal, sealWithTransferKey } from "./crypto";
16
16
 
@@ -27,7 +27,8 @@ export type PeerConfig = {
27
27
  pushAuth?: string;
28
28
  };
29
29
 
30
- type Listener = (p: Payload) => void;
30
+ /** Return true only after this consumer has durably accepted the payload. */
31
+ type Listener = (p: Payload) => boolean | void | Promise<boolean | void>;
31
32
 
32
33
  export type RelayClientOptions = {
33
34
  /** Persistent processes reconnect in the background; one-shot hooks leave this off. */
@@ -52,6 +53,15 @@ export class RelayClient {
52
53
  private reconnectTimer?: NodeJS.Timeout;
53
54
  private reconnectDelay: number;
54
55
  private readonly sessionKeys = new Map<string, string>();
56
+ /**
57
+ * Delivery ids are relay-controlled metadata, so replay protection is bound
58
+ * to the authenticated ciphertext itself. Keep a bounded process-local set:
59
+ * relay retries are acknowledged, but never delivered to the application
60
+ * twice even if an attacker changes the clear-text delivery id.
61
+ */
62
+ private readonly seenCiphertexts = new Set<string>();
63
+ private readonly processingCiphertexts = new Set<string>();
64
+ private static readonly MAX_SEEN_CIPHERTEXTS = 2_048;
55
65
 
56
66
  constructor(
57
67
  private readonly cfg: PeerConfig,
@@ -101,7 +111,7 @@ export class RelayClient {
101
111
  .catch(() => {});
102
112
  resolve();
103
113
  });
104
- ws.on("message", (data: WebSocket.RawData) => this.onRaw(data.toString()));
114
+ ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
105
115
  ws.on("error", (err) => {
106
116
  clearTimeout(timer);
107
117
  if (!opened) reject(err);
@@ -119,11 +129,13 @@ export class RelayClient {
119
129
  return this.connectPromise;
120
130
  }
121
131
 
122
- private onRaw(raw: string): void {
132
+ private async onRaw(raw: string): Promise<void> {
123
133
  const parsed = Envelope.safeParse(safeJson(raw));
124
134
  if (!parsed.success) return;
125
135
  const env = parsed.data;
126
136
  if (env.room !== this.cfg.room) return;
137
+ if (env.from !== this.otherRole()) return;
138
+ if (env.to !== this.cfg.role && env.to !== "all") return;
127
139
  if (env.expiresAt != null && env.expiresAt <= Date.now()) return;
128
140
  const body = open(env.nonce, env.box, this.cfg.peerPublicKey, this.cfg.mySecretKey);
129
141
  if (body === null) return; // not for us, or tampered
@@ -131,20 +143,60 @@ export class RelayClient {
131
143
  if (!parsedPayload.success) return;
132
144
  let payload: Payload = parsedPayload.data;
133
145
  if (payload.type === "session.key.grant") {
134
- this.sessionKeys.set(payload.sessionId, payload.key);
146
+ // Only the machine may attach a task and grant its independent key.
147
+ if (this.cfg.role !== "phone") return;
135
148
  } else if (payload.type === "session.sealed") {
136
149
  const key = this.sessionKeys.get(payload.sessionId);
137
150
  if (!key) return;
138
151
  const inner = openWithTransferKey(payload.nonce, payload.box, key);
139
152
  const parsedInner = Payload.safeParse(inner);
140
- if (!parsedInner.success || parsedInner.data.type === "session.key.grant"
141
- || parsedInner.data.type === "session.sealed") return;
153
+ if (
154
+ !parsedInner.success ||
155
+ parsedInner.data.type === "session.key.grant" ||
156
+ parsedInner.data.type === "session.sealed"
157
+ ) return;
158
+ const innerSessionId = "sessionId" in parsedInner.data ? parsedInner.data.sessionId : undefined;
159
+ if (typeof innerSessionId === "string" && innerSessionId !== payload.sessionId) return;
142
160
  payload = parsedInner.data;
143
161
  }
144
- if (env.deliveryId && this.ws?.readyState === WebSocket.OPEN) {
145
- this.ws.send(JSON.stringify({ type: "relay.ack", deliveryId: env.deliveryId }));
162
+
163
+ const fingerprint = createHash("sha256")
164
+ .update(env.nonce)
165
+ .update(".")
166
+ .update(env.box)
167
+ .digest("base64url");
168
+ if (this.seenCiphertexts.has(fingerprint)) {
169
+ this.ackDelivery(env.deliveryId);
170
+ return;
171
+ }
172
+ if (payload.type === "session.key.grant") {
173
+ this.sessionKeys.set(payload.sessionId, payload.key);
174
+ }
175
+ if (this.processingCiphertexts.has(fingerprint)) return;
176
+ this.processingCiphertexts.add(fingerprint);
177
+ try {
178
+ const results = await Promise.all([...this.listeners].map(async (listener) => {
179
+ try { return (await listener(payload)) === true; } catch { return false; }
180
+ }));
181
+ // Hello and key grants are completely consumed by RelayClient itself.
182
+ const accepted = payload.type === "hello" || payload.type === "session.key.grant"
183
+ || results.some(Boolean);
184
+ if (!accepted) return;
185
+ this.seenCiphertexts.add(fingerprint);
186
+ if (this.seenCiphertexts.size > RelayClient.MAX_SEEN_CIPHERTEXTS) {
187
+ const oldest = this.seenCiphertexts.values().next().value;
188
+ if (oldest) this.seenCiphertexts.delete(oldest);
189
+ }
190
+ this.ackDelivery(env.deliveryId);
191
+ } finally {
192
+ this.processingCiphertexts.delete(fingerprint);
193
+ }
194
+ }
195
+
196
+ private ackDelivery(deliveryId: string | undefined): void {
197
+ if (deliveryId && this.ws?.readyState === WebSocket.OPEN) {
198
+ this.ws.send(JSON.stringify({ type: "relay.ack", deliveryId }));
146
199
  }
147
- for (const l of this.listeners) l(payload);
148
200
  }
149
201
 
150
202
  async send(
@@ -214,7 +266,9 @@ export class RelayClient {
214
266
  clearTimeout(timer);
215
267
  off();
216
268
  resolve(p);
269
+ return true;
217
270
  }
271
+ return false;
218
272
  });
219
273
  });
220
274
  }
@@ -65,6 +65,13 @@ export const UserAttachment = z.object({
65
65
  });
66
66
  export type UserAttachment = z.infer<typeof UserAttachment>;
67
67
 
68
+ /**
69
+ * Task messages are sealed twice (task key, then device key), and each NaCl
70
+ * layer base64-encodes its ciphertext. This budget keeps the final frame below
71
+ * Cloudflare's 32 MiB WebSocket receive limit with JSON overhead.
72
+ */
73
+ export const MAX_ATTACHMENT_BASE64_CHARS = 16_000_000;
74
+
68
75
  export const UserMessage = z.object({
69
76
  type: z.literal("user.message"),
70
77
  /** Stable id makes phone retries idempotent and powers delivery receipts. */
@@ -477,7 +484,17 @@ export const Payload = z.discriminatedUnion("type", [
477
484
  SchedulePlanRequest,
478
485
  SchedulePlanResult,
479
486
  Hello,
480
- ]);
487
+ ]).superRefine((payload, ctx) => {
488
+ if (payload.type !== "user.message") return;
489
+ const encodedCharacters = payload.attachments?.reduce((total, item) => total + item.data.length, 0) ?? 0;
490
+ if (encodedCharacters > MAX_ATTACHMENT_BASE64_CHARS) {
491
+ ctx.addIssue({
492
+ code: z.ZodIssueCode.custom,
493
+ path: ["attachments"],
494
+ message: `attachments exceed the ${MAX_ATTACHMENT_BASE64_CHARS}-character encrypted transport budget`,
495
+ });
496
+ }
497
+ });
481
498
  export type Payload = z.infer<typeof Payload>;
482
499
 
483
500
  /** The routed unit. `nonce`+`box` are the sealed Payload; relay can't open it. */