@vellumai/vellum-gateway 0.12.3 → 0.12.4-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -469,11 +469,11 @@ The inbound message handler (`inbound-message-handler.ts`) accepts verification
469
469
 
470
470
  #### Explicit Rebind Policy
471
471
 
472
- Creating a new guardian challenge when a binding already exists for the `(assistantId, channel)` pair requires explicit `rebind: true` in the HTTP request. Without it, the daemon returns `already_bound` to the caller. This prevents accidental guardian replacement -- the desktop UI must explicitly acknowledge that it is replacing an existing guardian before a new challenge is issued. On the verification side, `validateAndConsumeVerification` always revokes any existing active binding before creating the new one, so the actual binding swap is atomic.
472
+ Creating a new guardian challenge when a binding already exists for the `(assistantId, channel)` pair requires explicit `rebind: true` in the HTTP request. Without it, the daemon returns `already_bound` to the caller. This prevents accidental guardian replacement -- the desktop UI must explicitly acknowledge that it is replacing an existing guardian before a new challenge is issued. Minting is all it permits on a text channel: a code never swaps the guardian's linked identity when it is redeemed (see below), so changing it means revoking the existing binding and then verifying again. An outbound phone verification is the exception: it is guardian-initiated by design, and its code replaces the bound number (`applyPhoneGuardianBindingGatewayWrites` in `gateway/src/verification/session-service.ts`).
473
473
 
474
474
  #### Guardian Takeover Prevention
475
475
 
476
- `validateAndConsumeVerification` rejects verification when an active binding exists for a _different_ external user. This prevents an attacker who intercepts a verification code from hijacking an established guardian binding. Same-user re-verification (e.g., re-verifying after a session timeout) is allowed, since the external user ID matches the existing binding.
476
+ Text-channel redemption (`applyGuardianSideEffects` in `gateway/src/verification/text-verification.ts`) rejects a guardian code when any active guardian binding on the channel belongs to a _different_ external user: the code is spent, the sender is told it was invalid or expired, and they are made neither guardian nor contact. This prevents an attacker who intercepts a verification code from hijacking an established guardian binding. Same-user re-verification (e.g., re-verifying after a session timeout) is allowed, since the external user ID matches the existing binding. A guardian who revoked their binding can verify the same account again: their own revoked row is reactivated when it belongs to the guardian contact, its stored address is exactly the redeeming one, and no other identity is linked on the channel. A revoked row that belonged to any other contact stays revoked. A blocked row is never reactivated.
477
477
 
478
478
  #### Guardian Verification Flow
479
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.12.3",
3
+ "version": "0.12.4-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,405 @@
1
+ /**
2
+ * The guardian is one person, and a channel holds at most one linked identity
3
+ * for them. On a text channel a guardian code never swaps that identity for
4
+ * another, and a guardian who removed their identity can link the same one
5
+ * again.
6
+ *
7
+ * The gateway DB and session store are real; the assistant mirror IPC is
8
+ * acknowledged and otherwise inert.
9
+ */
10
+
11
+ import {
12
+ afterAll,
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ mock,
18
+ test,
19
+ } from "bun:test";
20
+
21
+ import { hashVerificationSecret } from "@vellumai/gateway-client";
22
+
23
+ /** The daemon's answer to every IPC call; a test swaps it to fail. */
24
+ let ipcCallAssistantImpl: () => Promise<unknown> = async () => ({});
25
+
26
+ // Spread the actual module so untouched exports stay importable by
27
+ // later-loaded files when suites share a bun process.
28
+ const actualAssistantClient = await import("../ipc/assistant-client.js");
29
+ mock.module("../ipc/assistant-client.js", () => ({
30
+ ...actualAssistantClient,
31
+ ipcCallAssistant: () => ipcCallAssistantImpl(),
32
+ }));
33
+
34
+ await import("./test-preload.js");
35
+ const { getGatewayDb, initGatewayDb, resetGatewayDb } =
36
+ await import("../db/connection.js");
37
+ const { channelVerificationSessions, contactChannels, contacts } =
38
+ await import("../db/schema.js");
39
+ const { createInboundSession, createOutboundSession, getSessionById } =
40
+ await import("../db/session-store.js");
41
+ const { tryTextVerificationIntercept } =
42
+ await import("../verification/text-verification.js");
43
+
44
+ const CHANNEL = "slack";
45
+ const OLD_ACCOUNT = "U0OLD00001";
46
+ const NEW_ACCOUNT = "U0NEW00002";
47
+ const OTHER_ACCOUNT = "U0OTHER003";
48
+ const CODE = "123456";
49
+ const GUARDIAN_PRINCIPAL = "principal-guardian";
50
+ const INVALID_OR_EXPIRED = "The verification code is invalid or has expired.";
51
+
52
+ type ChannelStatus = "active" | "revoked" | "blocked";
53
+
54
+ function seedContact(id: string, role: "guardian" | "contact"): void {
55
+ const now = Date.now();
56
+ getGatewayDb()
57
+ .insert(contacts)
58
+ .values({
59
+ id,
60
+ displayName: id,
61
+ role,
62
+ principalId: role === "guardian" ? GUARDIAN_PRINCIPAL : null,
63
+ createdAt: now,
64
+ updatedAt: now,
65
+ })
66
+ .run();
67
+ }
68
+
69
+ function seedChannel(row: {
70
+ id: string;
71
+ contactId: string;
72
+ type: string;
73
+ address: string;
74
+ status: ChannelStatus;
75
+ }): void {
76
+ getGatewayDb()
77
+ .insert(contactChannels)
78
+ .values({
79
+ ...row,
80
+ externalChatId: row.address,
81
+ policy: row.status === "active" ? "allow" : "deny",
82
+ interactionCount: 0,
83
+ createdAt: Date.now(),
84
+ })
85
+ .run();
86
+ }
87
+
88
+ /** The guardian's own row on the channel, in the given state. */
89
+ function seedGuardianAccount(
90
+ address: string,
91
+ status: ChannelStatus = "active",
92
+ ): void {
93
+ seedChannel({
94
+ id: `guardian-${address}`,
95
+ contactId: "guardian",
96
+ type: CHANNEL,
97
+ address,
98
+ status,
99
+ });
100
+ }
101
+
102
+ function channelOf(address: string) {
103
+ return getGatewayDb()
104
+ .select()
105
+ .from(contactChannels)
106
+ .all()
107
+ .find((c) => c.type === CHANNEL && c.address === address);
108
+ }
109
+
110
+ function activeGuardianAccounts(): string[] {
111
+ return getGatewayDb()
112
+ .select()
113
+ .from(contactChannels)
114
+ .all()
115
+ .filter(
116
+ (c) =>
117
+ c.type === CHANNEL &&
118
+ c.contactId === "guardian" &&
119
+ c.status === "active",
120
+ )
121
+ .map((c) => c.address)
122
+ .sort();
123
+ }
124
+
125
+ /** A guardian code bound to an account. */
126
+ function mintCodeFor(
127
+ account: string,
128
+ session: { id: string; code: string } = { id: "session-1", code: CODE },
129
+ ): void {
130
+ createOutboundSession({
131
+ id: session.id,
132
+ channel: CHANNEL,
133
+ challengeHash: hashVerificationSecret(session.code),
134
+ expiresAt: Date.now() + 10 * 60 * 1000,
135
+ status: "awaiting_response",
136
+ expectedExternalUserId: account,
137
+ identityBindingStatus: "bound",
138
+ destinationAddress: account,
139
+ verificationPurpose: "guardian",
140
+ });
141
+ }
142
+
143
+ function redeem(code: string, from: string) {
144
+ // No callback URL: the reply comes back as text, as it does for email.
145
+ return tryTextVerificationIntercept({
146
+ sourceChannel: CHANNEL,
147
+ messageContent: code,
148
+ actorExternalUserId: from,
149
+ actorChatId: from,
150
+ isDirectMessage: true,
151
+ });
152
+ }
153
+
154
+ function expectRefused(result: Awaited<ReturnType<typeof redeem>>): void {
155
+ expect(result).toMatchObject({
156
+ intercepted: true,
157
+ outcome: "failed",
158
+ pendingReplyText: expect.stringContaining(INVALID_OR_EXPIRED),
159
+ });
160
+ // The code matched and is spent, so it cannot be tried again.
161
+ expect(getSessionById("session-1")?.status).toBe("consumed");
162
+ }
163
+
164
+ beforeAll(async () => {
165
+ await initGatewayDb();
166
+ });
167
+
168
+ beforeEach(() => {
169
+ ipcCallAssistantImpl = async () => ({});
170
+ getGatewayDb().delete(channelVerificationSessions).run();
171
+ getGatewayDb().delete(contactChannels).run();
172
+ getGatewayDb().delete(contacts).run();
173
+ seedContact("guardian", "guardian");
174
+ seedChannel({
175
+ id: "guardian-vellum",
176
+ contactId: "guardian",
177
+ type: "vellum",
178
+ address: GUARDIAN_PRINCIPAL,
179
+ status: "active",
180
+ });
181
+ });
182
+
183
+ afterAll(() => {
184
+ resetGatewayDb();
185
+ });
186
+
187
+ describe("a guardian code from an identity other than the one linked on the channel", () => {
188
+ beforeEach(() => {
189
+ seedGuardianAccount(OLD_ACCOUNT);
190
+ });
191
+
192
+ test("is refused, and the sender becomes neither guardian nor contact", async () => {
193
+ mintCodeFor(NEW_ACCOUNT);
194
+
195
+ expectRefused(await redeem(CODE, NEW_ACCOUNT));
196
+
197
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
198
+ expect(channelOf(NEW_ACCOUNT)).toBeUndefined();
199
+ });
200
+
201
+ test("is refused when it is an inbound challenge too", async () => {
202
+ const secret = "a".repeat(64);
203
+ createInboundSession({
204
+ id: "session-1",
205
+ channel: CHANNEL,
206
+ challengeHash: hashVerificationSecret(secret),
207
+ expiresAt: Date.now() + 10 * 60 * 1000,
208
+ });
209
+
210
+ expectRefused(await redeem(secret, NEW_ACCOUNT));
211
+
212
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
213
+ expect(channelOf(NEW_ACCOUNT)).toBeUndefined();
214
+ });
215
+
216
+ test("is refused even when the sender holds a second active guardian row", async () => {
217
+ // Two active guardian rows is not a state the product creates, and it is
218
+ // the one where reading a single row could pick the sender's own and
219
+ // then revoke the other.
220
+ seedGuardianAccount(OTHER_ACCOUNT);
221
+ mintCodeFor(OTHER_ACCOUNT);
222
+
223
+ expectRefused(await redeem(CODE, OTHER_ACCOUNT));
224
+
225
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT, OTHER_ACCOUNT]);
226
+ });
227
+
228
+ test("leaves the sender's own revoked row revoked while another identity is linked", async () => {
229
+ seedContact("former", "contact");
230
+ seedChannel({
231
+ id: "former-channel",
232
+ contactId: "former",
233
+ type: CHANNEL,
234
+ address: NEW_ACCOUNT,
235
+ status: "revoked",
236
+ });
237
+ mintCodeFor(NEW_ACCOUNT);
238
+
239
+ expectRefused(await redeem(CODE, NEW_ACCOUNT));
240
+
241
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
242
+ expect(channelOf(NEW_ACCOUNT)?.status).toBe("revoked");
243
+ });
244
+
245
+ test("refuses without the daemon when another identity is linked", async () => {
246
+ ipcCallAssistantImpl = async () => {
247
+ throw new Error("assistant unreachable");
248
+ };
249
+ mintCodeFor(NEW_ACCOUNT);
250
+
251
+ expectRefused(await redeem(CODE, NEW_ACCOUNT));
252
+
253
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
254
+ });
255
+
256
+ test("still lets the current guardian verify their own account again", async () => {
257
+ mintCodeFor(OLD_ACCOUNT);
258
+
259
+ expect(await redeem(CODE, OLD_ACCOUNT)).toMatchObject({
260
+ outcome: "verified",
261
+ trustClass: "guardian",
262
+ });
263
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
264
+ });
265
+ });
266
+
267
+ describe("a guardian code on a channel with no linked identity", () => {
268
+ test("binds a new account", async () => {
269
+ mintCodeFor(NEW_ACCOUNT);
270
+
271
+ expect(await redeem(CODE, NEW_ACCOUNT)).toMatchObject({
272
+ outcome: "verified",
273
+ trustClass: "guardian",
274
+ });
275
+ expect(activeGuardianAccounts()).toEqual([NEW_ACCOUNT]);
276
+ });
277
+
278
+ test("links only one identity when two codes are redeemed at once", async () => {
279
+ // Both redemptions are in flight before either binds. The second to
280
+ // reach the refusal check has to see the first one's binding.
281
+ mintCodeFor(NEW_ACCOUNT, { id: "session-1", code: "111111" });
282
+ mintCodeFor(OTHER_ACCOUNT, { id: "session-2", code: "222222" });
283
+
284
+ const results = await Promise.all([
285
+ redeem("111111", NEW_ACCOUNT),
286
+ redeem("222222", OTHER_ACCOUNT),
287
+ ]);
288
+
289
+ expect(
290
+ results
291
+ .map((r) => (r.intercepted ? r.outcome : "not intercepted"))
292
+ .sort(),
293
+ ).toEqual(["failed", "verified"]);
294
+ expect(activeGuardianAccounts()).toHaveLength(1);
295
+ });
296
+
297
+ test("binds with the name the channel gave when the daemon cannot answer", async () => {
298
+ ipcCallAssistantImpl = async () => {
299
+ throw new Error("assistant unreachable");
300
+ };
301
+ mintCodeFor(NEW_ACCOUNT);
302
+
303
+ expect(await redeem(CODE, NEW_ACCOUNT)).toMatchObject({
304
+ outcome: "verified",
305
+ trustClass: "guardian",
306
+ });
307
+ expect(activeGuardianAccounts()).toEqual([NEW_ACCOUNT]);
308
+ });
309
+
310
+ test("reconnects the account the guardian removed", async () => {
311
+ seedGuardianAccount(OLD_ACCOUNT, "revoked");
312
+ mintCodeFor(OLD_ACCOUNT);
313
+
314
+ expect(await redeem(CODE, OLD_ACCOUNT)).toMatchObject({
315
+ outcome: "verified",
316
+ trustClass: "guardian",
317
+ });
318
+ expect(activeGuardianAccounts()).toEqual([OLD_ACCOUNT]);
319
+ });
320
+
321
+ describe("from an address whose revoked row belonged to a contact, not the guardian", () => {
322
+ // Nothing else refuses here: no identity is linked, the row is revoked
323
+ // (not blocked) and its address matches exactly. Only the owner of the
324
+ // row separates this sender from the guardian reconnecting.
325
+ beforeEach(() => {
326
+ seedContact("former", "contact");
327
+ seedChannel({
328
+ id: "former-channel",
329
+ contactId: "former",
330
+ type: CHANNEL,
331
+ address: NEW_ACCOUNT,
332
+ status: "revoked",
333
+ });
334
+ });
335
+
336
+ function expectStillARevokedContact(): void {
337
+ expect(activeGuardianAccounts()).toEqual([]);
338
+ expect(channelOf(NEW_ACCOUNT)).toMatchObject({
339
+ contactId: "former",
340
+ status: "revoked",
341
+ });
342
+ }
343
+
344
+ test("a code bound to that address is refused", async () => {
345
+ mintCodeFor(NEW_ACCOUNT);
346
+
347
+ expectRefused(await redeem(CODE, NEW_ACCOUNT));
348
+
349
+ expectStillARevokedContact();
350
+ });
351
+
352
+ test("an inbound challenge, which is bound to no identity, is refused", async () => {
353
+ const secret = "b".repeat(64);
354
+ createInboundSession({
355
+ id: "session-1",
356
+ channel: CHANNEL,
357
+ challengeHash: hashVerificationSecret(secret),
358
+ expiresAt: Date.now() + 10 * 60 * 1000,
359
+ });
360
+
361
+ expectRefused(await redeem(secret, NEW_ACCOUNT));
362
+
363
+ expectStillARevokedContact();
364
+ });
365
+ });
366
+
367
+ test("connects a new account after the old one was removed", async () => {
368
+ seedGuardianAccount(OLD_ACCOUNT, "revoked");
369
+ mintCodeFor(NEW_ACCOUNT);
370
+
371
+ expect(await redeem(CODE, NEW_ACCOUNT)).toMatchObject({
372
+ outcome: "verified",
373
+ });
374
+ expect(activeGuardianAccounts()).toEqual([NEW_ACCOUNT]);
375
+ });
376
+
377
+ test("does not reconnect a revoked row stored under a different spelling of the address", async () => {
378
+ // The row is found case-insensitively, and reactivated only when its
379
+ // stored address is exactly the redeeming one.
380
+ seedGuardianAccount(OLD_ACCOUNT.toLowerCase(), "revoked");
381
+ mintCodeFor(OLD_ACCOUNT);
382
+
383
+ expectRefused(await redeem(CODE, OLD_ACCOUNT));
384
+
385
+ expect(activeGuardianAccounts()).toEqual([]);
386
+ expect(channelOf(OLD_ACCOUNT.toLowerCase())?.status).toBe("revoked");
387
+ });
388
+
389
+ test("never binds a blocked account", async () => {
390
+ seedContact("blocked", "contact");
391
+ seedChannel({
392
+ id: "blocked-channel",
393
+ contactId: "blocked",
394
+ type: CHANNEL,
395
+ address: NEW_ACCOUNT,
396
+ status: "blocked",
397
+ });
398
+ mintCodeFor(NEW_ACCOUNT);
399
+
400
+ expectRefused(await redeem(CODE, NEW_ACCOUNT));
401
+
402
+ expect(activeGuardianAccounts()).toEqual([]);
403
+ expect(channelOf(NEW_ACCOUNT)?.status).toBe("blocked");
404
+ });
405
+ });
@@ -170,14 +170,6 @@
170
170
  "description": "Enable Anthropic fast mode for Opus models (4.6, 4.7, 4.8), delivering up to 2.5x higher output tokens per second at premium pricing",
171
171
  "defaultEnabled": false
172
172
  },
173
- {
174
- "id": "teleport",
175
- "scope": "client",
176
- "key": "teleport",
177
- "label": "Teleport",
178
- "description": "Enable the local-to-platform teleport UI in General settings. Platform-to-local teleport is GA and not gated by this flag",
179
- "defaultEnabled": false
180
- },
181
173
  {
182
174
  "id": "apple-container",
183
175
  "scope": "client",
@@ -559,6 +551,14 @@
559
551
  "label": "Assistant Inbox",
560
552
  "description": "Shows an Assistant Inbox entry above Preferences in the web sidebar and the /assistant/inbox page it opens: a mailbox for the assistant's managed vellum.me address (received and sent, with a reading pane), the inline email setup card for entitled orgs with no address yet, and an upgrade card for orgs without the managed_email entitlement. Off hides the entry and redirects the route to chat.",
561
553
  "defaultEnabled": false
554
+ },
555
+ {
556
+ "id": "sidebar-done",
557
+ "scope": "both",
558
+ "key": "sidebar-done",
559
+ "label": "Sidebar Done",
560
+ "description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an Old chats page lists the full history, and done chats stay findable in search by default. Off keeps today's Archive wording, the overflow menu, and search that hides archived rows unless the query asks for them.",
561
+ "defaultEnabled": false
562
562
  }
563
563
  ]
564
564
  }
@@ -351,7 +351,7 @@ describe("file classification", () => {
351
351
  expect(result.reason).toContain("tools");
352
352
  });
353
353
 
354
- test("file_write to routes dir is high risk", async () => {
354
+ test("file_write to routes dir is medium risk", async () => {
355
355
  const result = await classify({
356
356
  tool: "file_write",
357
357
  path: "/workspace/routes/evil.ts",
@@ -366,7 +366,7 @@ describe("file classification", () => {
366
366
  skillSourceDirs: ["/workspace/.vellum/skills"],
367
367
  },
368
368
  });
369
- expect(result.risk).toBe("high");
369
+ expect(result.risk).toBe("medium");
370
370
  expect(result.reason).toContain("routes");
371
371
  });
372
372
 
@@ -246,21 +246,21 @@ describe("FileRiskClassifier", () => {
246
246
  expect(result.reason).toBe("Writes to hooks directory");
247
247
  });
248
248
 
249
- // Plugins directory escalation. The external plugin loader auto-imports
250
- // register.{ts,js} on daemon startup, so a routine file_write here could
251
- // plant persistent code execution.
252
- test("plugins directory itself is high", async () => {
249
+ // Plugins and routes are app authoring sinks: a sandbox file write there is
250
+ // Medium, which auto-approves at the default threshold while building apps
251
+ // and still prompts at a stricter one.
252
+ test("plugins directory itself is medium", async () => {
253
253
  testSkillSourceDirs = [];
254
254
  const result = await classifyInput({
255
255
  toolName: "file_write",
256
256
  filePath: MOCK_PLUGINS_DIR,
257
257
  workingDir: "/",
258
258
  });
259
- expect(result.riskLevel).toBe("high");
259
+ expect(result.riskLevel).toBe("medium");
260
260
  expect(result.reason).toBe("Writes to plugins directory");
261
261
  });
262
262
 
263
- test("register.ts inside plugins directory is high", async () => {
263
+ test("register.ts inside plugins directory is medium", async () => {
264
264
  testSkillSourceDirs = [];
265
265
  const registerFile = join(MOCK_PLUGINS_DIR, "evil-plugin", "register.ts");
266
266
  const result = await classifyInput({
@@ -268,11 +268,11 @@ describe("FileRiskClassifier", () => {
268
268
  filePath: registerFile,
269
269
  workingDir: "/",
270
270
  });
271
- expect(result.riskLevel).toBe("high");
271
+ expect(result.riskLevel).toBe("medium");
272
272
  expect(result.reason).toBe("Writes to plugins directory");
273
273
  });
274
274
 
275
- test("package.json inside plugins directory is high", async () => {
275
+ test("package.json inside plugins directory is medium", async () => {
276
276
  testSkillSourceDirs = [];
277
277
  const pkgFile = join(MOCK_PLUGINS_DIR, "evil-plugin", "package.json");
278
278
  const result = await classifyInput({
@@ -280,7 +280,7 @@ describe("FileRiskClassifier", () => {
280
280
  filePath: pkgFile,
281
281
  workingDir: "/",
282
282
  });
283
- expect(result.riskLevel).toBe("high");
283
+ expect(result.riskLevel).toBe("medium");
284
284
  expect(result.reason).toBe("Writes to plugins directory");
285
285
  });
286
286
 
@@ -343,20 +343,18 @@ describe("FileRiskClassifier", () => {
343
343
  expect(result.riskLevel).toBe("low");
344
344
  });
345
345
 
346
- // Routes directory escalation. The user-route dispatcher dynamic-imports
347
- // handler modules here and executes their exported HTTP-method functions.
348
- test("routes directory itself is high", async () => {
346
+ test("routes directory itself is medium", async () => {
349
347
  testSkillSourceDirs = [];
350
348
  const result = await classifyInput({
351
349
  toolName: "file_write",
352
350
  filePath: MOCK_ROUTES_DIR,
353
351
  workingDir: "/",
354
352
  });
355
- expect(result.riskLevel).toBe("high");
353
+ expect(result.riskLevel).toBe("medium");
356
354
  expect(result.reason).toBe("Writes to routes directory");
357
355
  });
358
356
 
359
- test("handler inside routes directory is high", async () => {
357
+ test("handler inside routes directory is medium", async () => {
360
358
  testSkillSourceDirs = [];
361
359
  const routeFile = join(MOCK_ROUTES_DIR, "evil.ts");
362
360
  const result = await classifyInput({
@@ -364,7 +362,7 @@ describe("FileRiskClassifier", () => {
364
362
  filePath: routeFile,
365
363
  workingDir: "/",
366
364
  });
367
- expect(result.riskLevel).toBe("high");
365
+ expect(result.riskLevel).toBe("medium");
368
366
  expect(result.reason).toBe("Writes to routes directory");
369
367
  });
370
368
 
@@ -437,14 +435,14 @@ describe("FileRiskClassifier", () => {
437
435
  expect(result.reason).toBe("Writes to tools directory");
438
436
  });
439
437
 
440
- test("/workspace-prefixed routes path is remapped and high", async () => {
438
+ test("/workspace-prefixed routes path is remapped and medium", async () => {
441
439
  testSkillSourceDirs = [];
442
440
  const result = await classifyInput({
443
441
  toolName: "file_write",
444
442
  filePath: "/workspace/routes/evil.ts",
445
443
  workingDir: MOCK_WORKSPACE_DIR,
446
444
  });
447
- expect(result.riskLevel).toBe("high");
445
+ expect(result.riskLevel).toBe("medium");
448
446
  expect(result.reason).toBe("Writes to routes directory");
449
447
  });
450
448
 
@@ -546,7 +544,7 @@ describe("FileRiskClassifier", () => {
546
544
  expect(result.reason).toBe("Writes to hooks directory");
547
545
  });
548
546
 
549
- test("plugins directory path is high", async () => {
547
+ test("plugins directory path is medium", async () => {
550
548
  testSkillSourceDirs = [];
551
549
  const registerFile = join(MOCK_PLUGINS_DIR, "evil-plugin", "register.ts");
552
550
  const result = await classifyInput({
@@ -554,7 +552,7 @@ describe("FileRiskClassifier", () => {
554
552
  filePath: registerFile,
555
553
  workingDir: "/",
556
554
  });
557
- expect(result.riskLevel).toBe("high");
555
+ expect(result.riskLevel).toBe("medium");
558
556
  expect(result.reason).toBe("Writes to plugins directory");
559
557
  });
560
558
 
@@ -570,7 +568,7 @@ describe("FileRiskClassifier", () => {
570
568
  expect(result.reason).toBe("Writes to tools directory");
571
569
  });
572
570
 
573
- test("routes directory path is high", async () => {
571
+ test("routes directory path is medium", async () => {
574
572
  testSkillSourceDirs = [];
575
573
  const routeFile = join(MOCK_ROUTES_DIR, "evil.ts");
576
574
  const result = await classifyInput({
@@ -578,9 +576,20 @@ describe("FileRiskClassifier", () => {
578
576
  filePath: routeFile,
579
577
  workingDir: "/",
580
578
  });
581
- expect(result.riskLevel).toBe("high");
579
+ expect(result.riskLevel).toBe("medium");
582
580
  expect(result.reason).toBe("Writes to routes directory");
583
581
  });
582
+
583
+ test("/workspace-prefixed plugin app source is remapped and medium", async () => {
584
+ testSkillSourceDirs = [];
585
+ const result = await classifyInput({
586
+ toolName: "file_edit",
587
+ filePath: "/workspace/plugins/my-plugin/apps/tracker/src/App.tsx",
588
+ workingDir: MOCK_WORKSPACE_DIR,
589
+ });
590
+ expect(result.riskLevel).toBe("medium");
591
+ expect(result.reason).toBe("Writes to plugins directory");
592
+ });
584
593
  });
585
594
 
586
595
  // -- host_file_read ---------------------------------------------------------
@@ -964,10 +973,36 @@ describe("FileRiskClassifier", () => {
964
973
  workingDir: WORKING_DIR,
965
974
  resolvedPath: join(MOCK_PLUGINS_DIR, "evil-plugin", "register.ts"),
966
975
  });
967
- expect(result.riskLevel).toBe("high");
976
+ expect(result.riskLevel).toBe("medium");
968
977
  expect(result.reason).toBe("Writes to plugins directory");
969
978
  });
970
979
 
980
+ // An app authoring sink on one side must not mask a High sink on the
981
+ // other: the higher-risk match wins.
982
+ test("file_write through a plugins-dir symlink into hooks is high", async () => {
983
+ testSkillSourceDirs = [];
984
+ const result = await classifyInput({
985
+ toolName: "file_write",
986
+ filePath: join(MOCK_PLUGINS_DIR, "my-plugin", "link.ts"),
987
+ workingDir: "/",
988
+ resolvedPath: join(MOCK_HOOKS_DIR, "pre-tool-use.sh"),
989
+ });
990
+ expect(result.riskLevel).toBe("high");
991
+ expect(result.reason).toBe("Writes to hooks directory");
992
+ });
993
+
994
+ test("file_edit through a hooks-dir symlink into plugins is high", async () => {
995
+ testSkillSourceDirs = [];
996
+ const result = await classifyInput({
997
+ toolName: "file_edit",
998
+ filePath: join(MOCK_HOOKS_DIR, "pre-tool-use.sh"),
999
+ workingDir: "/",
1000
+ resolvedPath: join(MOCK_PLUGINS_DIR, "my-plugin", "register.ts"),
1001
+ });
1002
+ expect(result.riskLevel).toBe("high");
1003
+ expect(result.reason).toBe("Writes to hooks directory");
1004
+ });
1005
+
971
1006
  test("file_edit escalates when resolvedPath lands in skill source", async () => {
972
1007
  const skillDir = "/home/user/skills/victim-skill";
973
1008
  testSkillSourceDirs = [skillDir];
@@ -1133,12 +1168,12 @@ describe("FileRiskClassifier", () => {
1133
1168
  testSkillSourceDirs = [];
1134
1169
  const result = await classifyInput({
1135
1170
  toolName: "file_write",
1136
- filePath: join(MOCK_PLUGINS_DIR, "evil", "register.ts"),
1137
- resolvedPath: join(MOCK_PLUGINS_DIR, "evil", "register.ts"),
1171
+ filePath: join(MOCK_HOOKS_DIR, "pre-tool-use.sh"),
1172
+ resolvedPath: join(MOCK_HOOKS_DIR, "pre-tool-use.sh"),
1138
1173
  resolvedWorkingDir: WORKING_DIR,
1139
1174
  });
1140
1175
  expect(result.riskLevel).toBe("high");
1141
- expect(result.reason).toBe("Writes to plugins directory");
1176
+ expect(result.reason).toBe("Writes to hooks directory");
1142
1177
  });
1143
1178
 
1144
1179
  test("in-workspace symlink whose real target escapes is medium", async () => {
@@ -11,11 +11,11 @@
11
11
  * targeting the actor token signing key or the monitoring data directory
12
12
  * (snapshot files may contain secrets).
13
13
  * - file_write / file_edit: Low by default, Medium when the target resolves
14
- * outside the sandbox working directory on a non-containerized install,
15
- * High if targeting skill source code, the workspace hooks directory, the
16
- * user plugins directory, the workspace tools directory, the workspace
17
- * routes directory, the workspace workflows directory, or the monitoring
18
- * data directory.
14
+ * outside the sandbox working directory on a non-containerized install or
15
+ * lands in an app authoring sink (the user plugins directory or the
16
+ * workspace routes directory), High if targeting skill source code, the
17
+ * workspace hooks directory, the workspace tools directory, the workspace
18
+ * workflows directory, or the monitoring data directory.
19
19
  * - host_file_read: Medium (tool registry default; no special escalation).
20
20
  * - host_file_write / host_file_edit: Medium by default, High if targeting
21
21
  * skill source code, the workspace hooks directory, the user plugins
@@ -28,19 +28,28 @@
28
28
  * routes directory, the workspace workflows directory, or the monitoring
29
29
  * data directory.
30
30
  *
31
- * The tools and routes directories are escalated for the same reason as
32
- * plugins: any file written under `<workspace>/tools/` is dynamic-imported
33
- * and executed as a registered tool by the workspace-tool loader (and its
34
- * live file watcher), and any file under `<workspace>/routes/` is
35
- * dynamic-imported and executed as an HTTP route handler. A write to either
36
- * is a code-injection sink, so it must clear the same High-risk approval gate
37
- * as hooks and plugins.
31
+ * The tools and routes directories are code-injection sinks for the same
32
+ * reason as plugins: any file written under `<workspace>/tools/` is
33
+ * dynamic-imported and executed as a registered tool by the workspace-tool
34
+ * loader (and its live file watcher), and any file under `<workspace>/routes/`
35
+ * is dynamic-imported and executed as an HTTP route handler.
38
36
  *
39
37
  * The workflows directory is escalated for the same reason: any file under
40
38
  * `<workspace>/workflows/` is a saved workflow whose source is executed (in the
41
39
  * sandbox, and unattended when triggered by a schedule), so it must clear the
42
40
  * same High-risk gate.
43
41
  *
42
+ * The plugins and routes directories are where apps are built: a plugin
43
+ * bundles its apps and their backing routes, and a workspace app's backend
44
+ * lives under `<workspace>/routes/`. Building and iterating on an app writes
45
+ * them on every turn, so a sandbox file write there classifies Medium: it
46
+ * auto-approves at the default threshold and still prompts at a stricter one.
47
+ * The same write through bash (`cat >`, `tee`, `sed -i`) classifies Medium or
48
+ * lower, so High on the file tools would gate only the well-behaved path.
49
+ * Host file tools keep High for every sink, and non-guardian actors stay on
50
+ * the assistant's capability floor for all of them
51
+ * (`isControlPlaneWorkspaceWrite`).
52
+ *
44
53
  * Gateway adaptation: accepts a FileClassificationContext parameter instead
45
54
  * of importing assistant platform utilities directly. The assistant is
46
55
  * responsible for constructing the context from its config/platform modules
@@ -458,12 +467,19 @@ function buildFileAllowlistOptions(
458
467
  return options;
459
468
  }
460
469
 
470
+ /**
471
+ * Which file tools are writing: the sandbox file tools (`file_write`,
472
+ * `file_edit`) or the host file tools (`host_file_write`, `host_file_edit`,
473
+ * `host_file_transfer`). Sets the level of an app authoring sink.
474
+ */
475
+ type SinkLane = "sandbox" | "host";
476
+
461
477
  /**
462
478
  * Classify a resolved (absolute) path against the code-injection sink
463
- * directories: skill source, hooks, plugins, tools, routes, and workflows. A
464
- * write to any of these plants code the daemon later executes, so it must clear
465
- * the High-risk approval gate. Returns a High assessment when the path lands in
466
- * a sink, or `null` when it doesn't.
479
+ * directories: skill source, hooks, plugins, tools, routes, workflows, and
480
+ * monitoring. A write to any of these plants code the daemon later executes.
481
+ * Every sink is High except the app authoring sinks (plugins and routes) in the
482
+ * sandbox lane, which are Medium. Returns `null` when the path lands in no sink.
467
483
  *
468
484
  * `verb` distinguishes the user-facing reason: "Writes" for write/edit tools,
469
485
  * "Transfers" for host_file_transfer.
@@ -472,33 +488,49 @@ function classifyCodeInjectionSink(
472
488
  resolvedPath: string,
473
489
  context: FileClassificationContext,
474
490
  verb: "Writes" | "Transfers",
491
+ lane: SinkLane,
475
492
  allowlistOptions: AllowlistOption[],
476
493
  ): RiskAssessment | null {
477
- const high = (target: string): RiskAssessment => ({
478
- riskLevel: "high",
494
+ const sink = (
495
+ target: string,
496
+ riskLevel: "medium" | "high" = "high",
497
+ ): RiskAssessment => ({
498
+ riskLevel,
479
499
  reason: `${verb} to ${target}`,
480
500
  scopeOptions: [],
481
501
  matchType: "registry",
482
502
  allowlistOptions,
483
503
  });
484
- if (isSkillSourcePath(resolvedPath, context))
485
- return high("skill source code");
486
- if (isHooksPath(resolvedPath, context)) return high("hooks directory");
487
- if (isPluginsPath(resolvedPath, context)) return high("plugins directory");
488
- if (isToolsPath(resolvedPath, context)) return high("tools directory");
489
- if (isRoutesPath(resolvedPath, context)) return high("routes directory");
490
- if (isWorkflowsPath(resolvedPath, context))
491
- return high("workflows directory");
492
- if (isMonitoringPath(resolvedPath, context))
493
- return high("monitoring directory");
504
+ const appAuthoringRisk = lane === "sandbox" ? "medium" : "high";
505
+ if (isSkillSourcePath(resolvedPath, context)) {
506
+ return sink("skill source code");
507
+ }
508
+ if (isHooksPath(resolvedPath, context)) {
509
+ return sink("hooks directory");
510
+ }
511
+ if (isPluginsPath(resolvedPath, context)) {
512
+ return sink("plugins directory", appAuthoringRisk);
513
+ }
514
+ if (isToolsPath(resolvedPath, context)) {
515
+ return sink("tools directory");
516
+ }
517
+ if (isRoutesPath(resolvedPath, context)) {
518
+ return sink("routes directory", appAuthoringRisk);
519
+ }
520
+ if (isWorkflowsPath(resolvedPath, context)) {
521
+ return sink("workflows directory");
522
+ }
523
+ if (isMonitoringPath(resolvedPath, context)) {
524
+ return sink("monitoring directory");
525
+ }
494
526
  return null;
495
527
  }
496
528
 
497
529
  /**
498
530
  * Run {@link classifyCodeInjectionSink} against both the lexical and the
499
- * symlink-resolved path, escalating if EITHER lands in a sink. A symlink can
500
- * mask a protected target two ways — a benign name pointing into a protected
501
- * dir (caught by the real path), or a path lexically inside a protected dir
531
+ * symlink-resolved path, returning the higher-risk match. A symlink can mask a
532
+ * protected target two ways: a benign name pointing into a protected dir
533
+ * (caught by the real path), or a path lexically inside a protected dir
502
534
  * pointing elsewhere, where the loader still executes the file through the
503
535
  * protected location (caught by the lexical path). When the two paths are
504
536
  * equal the check runs once.
@@ -508,17 +540,30 @@ function classifyCodeInjectionSinkEither(
508
540
  realPath: string,
509
541
  context: FileClassificationContext,
510
542
  verb: "Writes" | "Transfers",
543
+ lane: SinkLane,
511
544
  allowlistOptions: AllowlistOption[],
512
545
  ): RiskAssessment | null {
513
546
  const lexicalSink = classifyCodeInjectionSink(
514
547
  lexicalPath,
515
548
  context,
516
549
  verb,
550
+ lane,
517
551
  allowlistOptions,
518
552
  );
519
- if (lexicalSink) return lexicalSink;
520
- if (realPath === lexicalPath) return null;
521
- return classifyCodeInjectionSink(realPath, context, verb, allowlistOptions);
553
+ if (realPath === lexicalPath) {
554
+ return lexicalSink;
555
+ }
556
+ const realSink = classifyCodeInjectionSink(
557
+ realPath,
558
+ context,
559
+ verb,
560
+ lane,
561
+ allowlistOptions,
562
+ );
563
+ if (!realSink || lexicalSink?.riskLevel === "high") {
564
+ return lexicalSink;
565
+ }
566
+ return realSink;
522
567
  }
523
568
 
524
569
  // -- Classifier ---------------------------------------------------------------
@@ -528,7 +573,7 @@ function classifyCodeInjectionSinkEither(
528
573
  *
529
574
  * Classifies all seven file tool types by risk level, with escalation paths
530
575
  * for the code-injection sinks (skill source, hooks, plugins, tools, routes,
531
- * and workflows) and the actor token signing key.
576
+ * workflows, and monitoring) and the actor token signing key.
532
577
  *
533
578
  * Unlike the assistant version, this classifier accepts a
534
579
  * FileClassificationContext parameter on classify() instead of importing
@@ -634,6 +679,7 @@ export class FileRiskClassifier implements RiskClassifier<
634
679
  realPath,
635
680
  context,
636
681
  "Writes",
682
+ "sandbox",
637
683
  allowlistOptions,
638
684
  );
639
685
  if (sink) {
@@ -701,6 +747,7 @@ export class FileRiskClassifier implements RiskClassifier<
701
747
  realDest,
702
748
  context,
703
749
  "Transfers",
750
+ "host",
704
751
  allowlistOptions,
705
752
  );
706
753
  if (destSink) {
@@ -720,6 +767,7 @@ export class FileRiskClassifier implements RiskClassifier<
720
767
  realPath,
721
768
  context,
722
769
  actionVerb,
770
+ "host",
723
771
  allowlistOptions,
724
772
  );
725
773
  if (sink) {
@@ -47,6 +47,31 @@ export function getExistingGuardianBinding(
47
47
  return row ? { address: row.address } : null;
48
48
  }
49
49
 
50
+ /**
51
+ * Every address actively linked as the guardian's identity on a channel. The
52
+ * guardian is one person with at most one identity per channel, so this is
53
+ * normally zero or one address; a caller refusing to swap that identity reads
54
+ * them all.
55
+ */
56
+ export function activeGuardianAddresses(channel: string): string[] {
57
+ return getGatewayDb()
58
+ .select({ address: gwContactChannels.address })
59
+ .from(gwContacts)
60
+ .innerJoin(
61
+ gwContactChannels,
62
+ eq(gwContactChannels.contactId, gwContacts.id),
63
+ )
64
+ .where(
65
+ and(
66
+ eq(gwContacts.role, "guardian"),
67
+ eq(gwContactChannels.type, channel),
68
+ eq(gwContactChannels.status, "active"),
69
+ ),
70
+ )
71
+ .all()
72
+ .map((row) => row.address);
73
+ }
74
+
50
75
  /**
51
76
  * Return the most recent `contact_channels.updated_at` across any guardian
52
77
  * binding for a channel — active OR revoked. Returns `null` when no binding
@@ -370,9 +370,31 @@ export function gatewayChannelStatus(
370
370
  type: string,
371
371
  address: string,
372
372
  ): string | null {
373
+ return gatewayChannelRow(type, address)?.status ?? null;
374
+ }
375
+
376
+ /**
377
+ * The authoritative gateway row behind {@link gatewayChannelStatus}, with the
378
+ * address as stored, for a caller that has to compare it exactly, and the
379
+ * role of the contact that owns it, for a caller that has to know whether the
380
+ * row is the guardian's.
381
+ *
382
+ * A left join: the status is reported for the row whatever its contact, so a
383
+ * blocked row reads as blocked even if its owner cannot be found, and such a
384
+ * row carries a null role, which is not the guardian's.
385
+ */
386
+ export function gatewayChannelRow(
387
+ type: string,
388
+ address: string,
389
+ ): { status: string; address: string; contactRole: string | null } | null {
373
390
  const row = getGatewayDb()
374
- .select({ status: gwContactChannels.status })
391
+ .select({
392
+ status: gwContactChannels.status,
393
+ address: gwContactChannels.address,
394
+ contactRole: gwContacts.role,
395
+ })
375
396
  .from(gwContactChannels)
397
+ .leftJoin(gwContacts, eq(gwContactChannels.contactId, gwContacts.id))
376
398
  .where(
377
399
  and(
378
400
  eq(gwContactChannels.type, type),
@@ -380,7 +402,7 @@ export function gatewayChannelStatus(
380
402
  ),
381
403
  )
382
404
  .get();
383
- return row?.status ?? null;
405
+ return row ?? null;
384
406
  }
385
407
 
386
408
  export interface VerifiedChannelRow {
@@ -26,7 +26,7 @@ import {
26
26
  import { getLogger } from "../logger.js";
27
27
 
28
28
  import {
29
- getExistingGuardianBinding,
29
+ activeGuardianAddresses,
30
30
  resolveCanonicalPrincipal,
31
31
  revokeExistingChannelGuardian,
32
32
  } from "./binding-helpers.js";
@@ -37,7 +37,7 @@ import {
37
37
  } from "./code-parsing.js";
38
38
  import {
39
39
  findContactChannelByAddress,
40
- gatewayChannelStatus,
40
+ gatewayChannelRow,
41
41
  upsertVerifiedContactChannel,
42
42
  } from "./contact-helpers.js";
43
43
  import { canonicalizeInboundIdentity } from "./identity.js";
@@ -252,7 +252,9 @@ export async function tryTextVerificationIntercept(
252
252
  : "guardian";
253
253
 
254
254
  // 7. Apply side effects. A blocked/revoked authoritative gateway row rejects
255
- // the verification: the actor must not regain trusted status nor see a
255
+ // the verification, and so does a guardian code from an identity other
256
+ // than the one linked on the channel: the actor must not gain trusted
257
+ // status nor see a
256
258
  // success reply, even though the code matched and the session consumed.
257
259
  const sideEffectsVerified =
258
260
  trustClass === "guardian"
@@ -274,7 +276,7 @@ export async function tryTextVerificationIntercept(
274
276
  if (!sideEffectsVerified) {
275
277
  log.warn(
276
278
  { sourceChannel, actorExternalUserId: canonicalUserId, trustClass },
277
- "Verification rejected: authoritative gateway channel is blocked/revoked",
279
+ "Verification rejected: the consumed code granted nothing",
278
280
  );
279
281
  const pendingReplyText = await replyWithFailure(
280
282
  replyCallbackUrl,
@@ -341,27 +343,50 @@ async function applyGuardianSideEffects(params: {
341
343
  actorUsername,
342
344
  } = params;
343
345
 
344
- // Check for binding conflict — another user already holds guardian
345
- const existing = getExistingGuardianBinding(sourceChannel);
346
- if (existing?.address && existing.address !== canonicalUserId) {
346
+ // The only await before the binding is written, so it runs first. From the
347
+ // refusal check below to the gateway writes inside createGuardianBinding
348
+ // (a synchronous transaction, ahead of that function's own first await)
349
+ // nothing yields, so two codes redeemed at once cannot both pass the check.
350
+ // A sender verifying again keeps the name their contact already has. The
351
+ // read is for that name only: a daemon that cannot answer falls back to the
352
+ // name the channel gave, so no outcome below depends on the daemon.
353
+ let existingContact: Awaited<ReturnType<typeof findContactChannelByAddress>> =
354
+ null;
355
+ try {
356
+ existingContact = await findContactChannelByAddress(
357
+ sourceChannel,
358
+ canonicalUserId,
359
+ );
360
+ } catch (err) {
361
+ log.warn(
362
+ { err, sourceChannel },
363
+ "Guardian display name lookup failed; using the name the channel gave",
364
+ );
365
+ }
366
+ const displayName = existingContact?.displayName?.trim().length
367
+ ? existingContact.displayName
368
+ : (actorDisplayName ?? actorUsername ?? canonicalUserId);
369
+
370
+ // The guardian is one person, and a channel holds at most one linked
371
+ // identity for them. A guardian code links an identity where none is linked,
372
+ // or links the same one again. It never swaps one identity for another, and
373
+ // grants nothing in its place: swapping is two explicit acts, remove the
374
+ // link and then connect again. Every active row is read, so a second one is
375
+ // weighed and not hidden behind a LIMIT 1. This is the text-channel rule; an
376
+ // outbound phone code replaces the bound number (session-service.ts).
377
+ const otherLinkedIdentities = activeGuardianAddresses(sourceChannel).filter(
378
+ (address) => address !== canonicalUserId,
379
+ );
380
+ if (otherLinkedIdentities.length > 0) {
347
381
  log.warn(
348
382
  {
349
383
  sourceChannel,
350
- existingGuardian: existing.address,
384
+ linkedIdentities: otherLinkedIdentities,
351
385
  newActor: canonicalUserId,
352
386
  },
353
- "Guardian binding conflict: another user already holds this channel",
387
+ "Guardian code refused: a different identity is linked as the guardian on this channel",
354
388
  );
355
- // Still upsert the contact channel so the sender is a known contact,
356
- // but skip guardian binding creation.
357
- const { verified } = await upsertVerifiedContactChannel({
358
- sourceChannel,
359
- externalUserId: canonicalUserId,
360
- externalChatId: actorChatId,
361
- displayName: actorDisplayName,
362
- username: actorUsername,
363
- });
364
- return verified;
389
+ return false;
365
390
  }
366
391
 
367
392
  // The gateway is the source of truth: a blocked/revoked gateway row rejects
@@ -369,13 +394,27 @@ async function applyGuardianSideEffects(params: {
369
394
  // re-verifying guardian (whose current row is active) isn't blocked by their
370
395
  // own about-to-be-revoked row. createGuardianBinding writes "active"
371
396
  // unconditionally, so this guard is the only thing stopping a blocked actor.
372
- const gwStatus = gatewayChannelStatus(sourceChannel, canonicalUserId);
397
+ //
398
+ // One revoked row gets past it: the guardian's own, on a channel with no
399
+ // other linked identity. That is the guardian linking again the identity
400
+ // they removed. The row has to belong to the guardian contact: a contact the
401
+ // guardian revoked stays revoked whatever code they hold. A blocked row
402
+ // never gets past it.
403
+ const gwRow = gatewayChannelRow(sourceChannel, canonicalUserId);
404
+ const gwStatus = gwRow?.status ?? null;
373
405
  if (gwStatus === "blocked" || gwStatus === "revoked") {
374
- log.warn(
375
- { sourceChannel, address: canonicalUserId, status: gwStatus },
376
- "Skipping guardian binding: authoritative gateway channel is blocked or revoked",
377
- );
378
- return false;
406
+ const reconnectsOwnRevokedRow =
407
+ gwStatus === "revoked" &&
408
+ gwRow?.contactRole === "guardian" &&
409
+ gwRow?.address === canonicalUserId &&
410
+ otherLinkedIdentities.length === 0;
411
+ if (!reconnectsOwnRevokedRow) {
412
+ log.warn(
413
+ { sourceChannel, address: canonicalUserId, status: gwStatus },
414
+ "Skipping guardian binding: authoritative gateway channel is blocked or revoked",
415
+ );
416
+ return false;
417
+ }
379
418
  }
380
419
 
381
420
  // Revoke existing binding (same-user re-verification)
@@ -384,15 +423,6 @@ async function applyGuardianSideEffects(params: {
384
423
  // Resolve canonical principal — unify all channel bindings
385
424
  const canonicalPrincipal = resolveCanonicalPrincipal(canonicalUserId);
386
425
 
387
- // Determine display name — preserve existing if user is re-verifying
388
- const existingContact = await findContactChannelByAddress(
389
- sourceChannel,
390
- canonicalUserId,
391
- );
392
- const displayName = existingContact?.displayName?.trim().length
393
- ? existingContact.displayName
394
- : (actorDisplayName ?? actorUsername ?? canonicalUserId);
395
-
396
426
  // Create guardian binding (dual-writes to both DBs)
397
427
  await createGuardianBinding({
398
428
  channel: sourceChannel,