@vellumai/vellum-gateway 0.12.3 → 0.12.4-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +2 -2
- package/package.json +1 -1
- package/src/__tests__/text-verification-guardian-conflict.test.ts +405 -0
- package/src/feature-flag-registry.json +8 -8
- package/src/ipc/risk-classification-handlers.test.ts +2 -2
- package/src/risk/file-risk-classifier.test.ts +60 -25
- package/src/risk/file-risk-classifier.ts +83 -35
- package/src/verification/binding-helpers.ts +25 -0
- package/src/verification/contact-helpers.ts +24 -2
- package/src/verification/text-verification.ts +64 -34
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.
|
|
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
|
-
`
|
|
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
|
@@ -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
|
|
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("
|
|
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
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
test("plugins directory itself is
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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("
|
|
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(
|
|
1137
|
-
resolvedPath: join(
|
|
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
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
32
|
-
* plugins: any file written under `<workspace>/tools/` is
|
|
33
|
-
* and executed as a registered tool by the workspace-tool
|
|
34
|
-
* live file watcher), and any file under `<workspace>/routes/`
|
|
35
|
-
* dynamic-imported and executed as an HTTP route handler.
|
|
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,
|
|
464
|
-
* write to any of these plants code the daemon later executes
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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
|
|
478
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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,
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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 (
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
384
|
+
linkedIdentities: otherLinkedIdentities,
|
|
351
385
|
newActor: canonicalUserId,
|
|
352
386
|
},
|
|
353
|
-
"Guardian
|
|
387
|
+
"Guardian code refused: a different identity is linked as the guardian on this channel",
|
|
354
388
|
);
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
"
|
|
377
|
-
|
|
378
|
-
|
|
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,
|