@vellumai/vellum-gateway 0.12.6 → 0.12.7-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/AGENTS.md +2 -2
- package/ARCHITECTURE.md +6 -1
- package/README.md +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
- package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/package.json +1 -1
- package/src/__tests__/channel-admission-policy-routes.test.ts +18 -14
- package/src/__tests__/contact-store-bind-principal.test.ts +270 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +32 -0
- package/src/__tests__/devices.test.ts +92 -2
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +135 -2
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -0
- package/src/__tests__/guardian-integrity.test.ts +11 -9
- package/src/__tests__/guardian-request-decide.test.ts +31 -0
- package/src/__tests__/helpers/contact-fixtures.ts +13 -5
- package/src/__tests__/ipc-contact-routes.test.ts +52 -0
- package/src/__tests__/refresh-device-binding.test.ts +59 -0
- package/src/__tests__/remote-web-ingress-denylist.test.ts +1 -0
- package/src/__tests__/route-schema-guard.test.ts +3 -0
- package/src/__tests__/scopes.test.ts +36 -0
- package/src/__tests__/seed-admission-policy.test.ts +20 -0
- package/src/__tests__/shared-invite-redeem.test.ts +682 -0
- package/src/__tests__/shared-invite-registration.test.ts +332 -0
- package/src/__tests__/upsert-verified-contact-channel.test.ts +38 -1
- package/src/auth/guardian-bootstrap.ts +162 -12
- package/src/auth/guardian-integrity.ts +5 -3
- package/src/auth/guardian-refresh.ts +9 -1
- package/src/auth/scopes.ts +15 -0
- package/src/auth/types.ts +1 -0
- package/src/channels/types.ts +1 -0
- package/src/db/admission-policy-store.ts +2 -2
- package/src/db/contact-store.ts +134 -0
- package/src/db/schema.ts +12 -0
- package/src/db/seed-admission-policy.ts +9 -5
- package/src/feature-flag-registry.json +16 -8
- package/src/http/routes/contacts-control-plane-proxy.ts +137 -34
- package/src/http/routes/ipc-runtime-proxy.test.ts +339 -11
- package/src/http/routes/ipc-runtime-proxy.ts +86 -22
- package/src/http/routes/shared-invite-redeem.ts +140 -0
- package/src/index.ts +10 -0
- package/src/ipc/contact-handlers.ts +22 -0
- package/src/ipc/route-schema-cache.test.ts +36 -0
- package/src/ipc/route-schema-cache.ts +12 -6
- package/src/verification/contact-helpers.ts +12 -1
- package/src/verification/invite-redemption.ts +53 -8
- package/src/verification/shared-invite-redemption.ts +118 -0
- package/src/verification/shared-invite-registration.ts +121 -0
package/AGENTS.md
CHANGED
|
@@ -169,7 +169,7 @@ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolic
|
|
|
169
169
|
| Policy | Floor | Notes |
|
|
170
170
|
| ------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
171
171
|
| `no_one` | 5 | Hard-deny at gateway _before_ forwarding (kill switch in `handle-inbound.ts`). Includes the guardian — this channel is _OFF_. |
|
|
172
|
-
| `guardian_only` | 4 | Seeded default for `vellum`.
|
|
172
|
+
| `guardian_only` | 4 | Seeded default for `vellum` and `vellum-shared`. |
|
|
173
173
|
| `trusted_contacts` | 3 | Seeded default for all other channels; also the read-path safety fallback. |
|
|
174
174
|
| `any_contact` | 2 | May surface Slack DM / email upgrade challenge on deny. |
|
|
175
175
|
| `strangers` | 1 | May surface upgrade challenge. |
|
|
@@ -183,7 +183,7 @@ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolic
|
|
|
183
183
|
|
|
184
184
|
For exempt ids, `PUT /v1/assistants/:id/channel-admission-policy/:channelType` returns **403**, the GET list omits them, and the runtime short-circuits `admitted: true` in `admission-policy.ts` (defense in depth). Codex finding from #35006 review: exemption checks must live in _both_ the gateway route handler AND the runtime stage — single-side enforcement creates a misuse wedge.
|
|
185
185
|
|
|
186
|
-
**Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `whatsapp`)
|
|
186
|
+
**Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `vellum-shared`, `whatsapp`): managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
|
|
187
187
|
|
|
188
188
|
- The GET list omits them, and `PUT`/`DELETE` return **403** (`isAdmissionPolicyHiddenChannel`).
|
|
189
189
|
- They are **not** exempt — the runtime still evaluates rank-vs-floor, so a real inbound channel like `whatsapp` keeps its admission floor.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -207,10 +207,11 @@ All contact paths are registered flat only (`/v1/contacts...`, `/v1/contact-chan
|
|
|
207
207
|
| DELETE | `/v1/contacts/invites/:inviteId` |
|
|
208
208
|
| POST | `/v1/contacts/invites/:inviteId/call` |
|
|
209
209
|
| POST | `/v1/contacts/invites/redeem` |
|
|
210
|
+
| POST | `/v1/shared/invites/redeem` |
|
|
210
211
|
|
|
211
212
|
**Authentication boundary:**
|
|
212
213
|
|
|
213
|
-
- Gateway validates the caller's JWT bearer token.
|
|
214
|
+
- Gateway validates the caller's JWT bearer token, except on `/v1/shared/invites/redeem`, where the `vellum-shared` invite link token in the body is the credential (see Invite-based onboarding).
|
|
214
215
|
- Native contact/invite endpoints are served from the gateway DB after that bearer-auth check; runtime-relayed requests (Telegram control plane, contact search reads) reach the runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
|
|
215
216
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
216
217
|
|
|
@@ -609,6 +610,8 @@ The channel inbound handler (`inbound-message-handler.ts`) enforces an access co
|
|
|
609
610
|
|
|
610
611
|
**Invite-based onboarding:** Invite tokens are minted by the gateway via the invite HTTP API and stored SHA-256 hashed on the gateway DB's `ingress_invites` row -- the raw token is returned exactly once at creation time. External users redeem invites by sending the token as a channel message, which atomically creates a member record with `active` status and `allow` policy.
|
|
611
612
|
|
|
613
|
+
A `vellum-shared` invite is redeemed instead through `POST /v1/shared/invites/redeem`, which the platform calls on the invitee's behalf with the link token and a device id. In one gateway DB transaction it claims the invite's single use, mints a principal, binds it to the invite's contact-role contact, records it as that contact's active `vellum-shared` channel, and mints a device-bound `contact`-role token pair; a failure at any step rolls all of it back and leaves the invite redeemable. `writeSharedPrincipalChannel` is the only writer of `vellum-shared` rows. The route accepts only the link token, never the 6-digit code, answers 404 while `vellum-trusted-contacts` is off, limits failed attempts per client IP, and is denied at the remote web ingress. The principal then resolves as `trusted_contact` through its `vellum-shared` channel for as long as that channel stays active.
|
|
614
|
+
|
|
612
615
|
**Relationship to guardian verification:** Guardian verification and ingress contact management are independent systems. Guardian verification establishes who controls the assistant on a channel (the trust anchor for approvals). Ingress contacts control who can interact with the assistant.
|
|
613
616
|
|
|
614
617
|
#### SQLite Tables
|
|
@@ -639,6 +642,8 @@ The gateway declares `contacts` and `contact_channels` tables and exposes them v
|
|
|
639
642
|
| `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Gateway-native invite lifecycle (mint, list, revoke, redeem) shared by HTTP and IPC |
|
|
640
643
|
| `gateway/src/ipc/invite-handlers.ts` | IPC routes relaying the daemon's invite surfaces to the native functions |
|
|
641
644
|
| `gateway/src/verification/invite-redemption.ts` | Redemption engine — validation, atomic claim, ACL activation |
|
|
645
|
+
| `gateway/src/verification/shared-invite-redemption.ts` | `vellum-shared` invite redemption: claim, principal, channel and token pair in one transaction |
|
|
646
|
+
| `gateway/src/http/routes/shared-invite-redeem.ts` | `POST /v1/shared/invites/redeem`: flag gate, request schema, per-IP failure limit |
|
|
642
647
|
| `assistant/src/contacts/contact-store.ts` | Contact and channel lookups (findContactChannel, guardian bindings) |
|
|
643
648
|
| `assistant/src/contacts/contacts-write.ts` | Contact and channel identity/info writes (upsert, redemption info mirror — ACL is gateway-owned) |
|
|
644
649
|
| `assistant/src/ipc/routes/invite-ipc-routes.ts` | `invite_redeemed` info mirror — local contact/channel identity upsert |
|
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Control-plane routes are listed with their flat paths. Clients emit assistant-sc
|
|
|
156
156
|
| `/v1/contacts/invites/:id` | DELETE | Gateway-native invite revoke |
|
|
157
157
|
| `/v1/contacts/invites/:id/call` | POST | Gateway-native invite-call relay — validates the invite row, then delegates the provider call to the assistant |
|
|
158
158
|
| `/v1/contacts/invites/redeem` | POST | Gateway-native invite redemption (voice code and link token) |
|
|
159
|
+
| `/v1/shared/invites/redeem` | POST | Unauthenticated `vellum-shared` invite redemption for a contact principal and token pair; 404 while `vellum-trusted-contacts` is off |
|
|
159
160
|
| `/v1/health` | GET | Authenticated runtime health proxy (`/v1/health` on runtime) |
|
|
160
161
|
| `/healthz` | GET | Liveness probe |
|
|
161
162
|
| `/readyz` | GET | Readiness probe |
|
|
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
|
|
|
24
24
|
// so these must remain canonical for that assertion to mean anything.
|
|
25
25
|
expect(isChannelId("platform")).toBe(true);
|
|
26
26
|
expect(isChannelId("vellum")).toBe(true);
|
|
27
|
+
expect(isChannelId("vellum-shared")).toBe(true);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test("includes discord", () => {
|
|
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
|
|
|
58
59
|
test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
|
|
59
60
|
expect(botProviderForChannel("phone")).toBeUndefined();
|
|
60
61
|
expect(botProviderForChannel("vellum")).toBeUndefined();
|
|
62
|
+
expect(botProviderForChannel("vellum-shared")).toBeUndefined();
|
|
61
63
|
expect(botProviderForChannel("not-a-channel")).toBeUndefined();
|
|
62
64
|
// `slack` is the person's integration standing beside the bot's key.
|
|
63
65
|
expect(channelForBotProvider("slack")).toBeUndefined();
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A "channel" is an external messaging surface an actor can reach the
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
|
-
* internal ids (`vellum` for native app conversations, `
|
|
8
|
-
*
|
|
7
|
+
* internal ids (`vellum` for native app conversations, `vellum-shared` for
|
|
8
|
+
* conversations a guardian shares with a contact, `platform` for the internal
|
|
9
|
+
* control plane). This is the single source of truth for that set:
|
|
9
10
|
*
|
|
10
11
|
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
12
|
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
|
|
|
41
42
|
"a2a",
|
|
42
43
|
"discord",
|
|
43
44
|
"plugin",
|
|
45
|
+
// Internal id for conversations a guardian shares with a contact. It has no
|
|
46
|
+
// transport: the contact's own client holds the connection.
|
|
47
|
+
"vellum-shared",
|
|
44
48
|
] as const;
|
|
45
49
|
|
|
46
50
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
78
82
|
* here would be a second copy of a different fact.
|
|
79
83
|
*
|
|
80
84
|
* Channels absent from this map reach the assistant without a bot credential
|
|
81
|
-
* of their own: `phone` through the voice provider, `vellum
|
|
82
|
-
* internally.
|
|
85
|
+
* of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
|
|
86
|
+
* and `platform` internally.
|
|
83
87
|
*/
|
|
84
88
|
export const CHANNEL_BOT_PROVIDER = {
|
|
85
89
|
slack: "slack_channel",
|
|
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
|
|
|
24
24
|
// so these must remain canonical for that assertion to mean anything.
|
|
25
25
|
expect(isChannelId("platform")).toBe(true);
|
|
26
26
|
expect(isChannelId("vellum")).toBe(true);
|
|
27
|
+
expect(isChannelId("vellum-shared")).toBe(true);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test("includes discord", () => {
|
|
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
|
|
|
58
59
|
test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
|
|
59
60
|
expect(botProviderForChannel("phone")).toBeUndefined();
|
|
60
61
|
expect(botProviderForChannel("vellum")).toBeUndefined();
|
|
62
|
+
expect(botProviderForChannel("vellum-shared")).toBeUndefined();
|
|
61
63
|
expect(botProviderForChannel("not-a-channel")).toBeUndefined();
|
|
62
64
|
// `slack` is the person's integration standing beside the bot's key.
|
|
63
65
|
expect(channelForBotProvider("slack")).toBeUndefined();
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A "channel" is an external messaging surface an actor can reach the
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
|
-
* internal ids (`vellum` for native app conversations, `
|
|
8
|
-
*
|
|
7
|
+
* internal ids (`vellum` for native app conversations, `vellum-shared` for
|
|
8
|
+
* conversations a guardian shares with a contact, `platform` for the internal
|
|
9
|
+
* control plane). This is the single source of truth for that set:
|
|
9
10
|
*
|
|
10
11
|
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
12
|
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
|
|
|
41
42
|
"a2a",
|
|
42
43
|
"discord",
|
|
43
44
|
"plugin",
|
|
45
|
+
// Internal id for conversations a guardian shares with a contact. It has no
|
|
46
|
+
// transport: the contact's own client holds the connection.
|
|
47
|
+
"vellum-shared",
|
|
44
48
|
] as const;
|
|
45
49
|
|
|
46
50
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
78
82
|
* here would be a second copy of a different fact.
|
|
79
83
|
*
|
|
80
84
|
* Channels absent from this map reach the assistant without a bot credential
|
|
81
|
-
* of their own: `phone` through the voice provider, `vellum
|
|
82
|
-
* internally.
|
|
85
|
+
* of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
|
|
86
|
+
* and `platform` internally.
|
|
83
87
|
*/
|
|
84
88
|
export const CHANNEL_BOT_PROVIDER = {
|
|
85
89
|
slack: "slack_channel",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isTrustCheckedScopeProfile,
|
|
5
|
+
routeAdmitsTrustClass,
|
|
6
|
+
tokenMayReachRoute,
|
|
7
|
+
TRUST_EXEMPT_SCOPE_PROFILES,
|
|
8
|
+
} from "../route-trust-class.js";
|
|
9
|
+
|
|
10
|
+
const CONTACT_ROUTE = ["guardian", "trusted_contact", "unverified_contact"];
|
|
11
|
+
|
|
12
|
+
const contactMayReach = (
|
|
13
|
+
allowed: readonly string[] | undefined,
|
|
14
|
+
resolve: () => Promise<string | undefined>,
|
|
15
|
+
) => tokenMayReachRoute("contact_client_v1", allowed, resolve);
|
|
16
|
+
|
|
17
|
+
describe("routeAdmitsTrustClass", () => {
|
|
18
|
+
test("an absent list admits the guardian only", () => {
|
|
19
|
+
expect(routeAdmitsTrustClass(undefined, "guardian")).toBe(true);
|
|
20
|
+
expect(routeAdmitsTrustClass(undefined, "trusted_contact")).toBe(false);
|
|
21
|
+
expect(routeAdmitsTrustClass(undefined, "unknown")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a listed class is admitted and an unlisted one refused", () => {
|
|
25
|
+
expect(routeAdmitsTrustClass(CONTACT_ROUTE, "trusted_contact")).toBe(true);
|
|
26
|
+
expect(routeAdmitsTrustClass(CONTACT_ROUTE, "unknown")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a value outside the vocabulary is refused even when listed", () => {
|
|
30
|
+
expect(routeAdmitsTrustClass(["owner"], "owner")).toBe(false);
|
|
31
|
+
expect(routeAdmitsTrustClass(CONTACT_ROUTE, undefined)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("tokenMayReachRoute for a contact token", () => {
|
|
36
|
+
test("a route admitting only the guardian refuses without resolving", async () => {
|
|
37
|
+
const resolve = mock(async () => "trusted_contact");
|
|
38
|
+
expect(await contactMayReach(undefined, resolve)).toBe(false);
|
|
39
|
+
expect(await contactMayReach(["guardian"], resolve)).toBe(false);
|
|
40
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test.each([
|
|
44
|
+
["trusted_contact", true],
|
|
45
|
+
["unverified_contact", true],
|
|
46
|
+
["unknown", false],
|
|
47
|
+
["guardian", false],
|
|
48
|
+
[undefined, false],
|
|
49
|
+
] as const)(
|
|
50
|
+
"a contact route resolving %p admits: %p",
|
|
51
|
+
async (trustClass, admitted) => {
|
|
52
|
+
expect(await contactMayReach(CONTACT_ROUTE, async () => trustClass)).toBe(
|
|
53
|
+
admitted,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
test("a class the route does not list is refused", async () => {
|
|
59
|
+
expect(
|
|
60
|
+
await contactMayReach(
|
|
61
|
+
["guardian", "trusted_contact"],
|
|
62
|
+
async () => "unverified_contact",
|
|
63
|
+
),
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a resolver that throws refuses", async () => {
|
|
68
|
+
expect(
|
|
69
|
+
await contactMayReach(CONTACT_ROUTE, async () => {
|
|
70
|
+
throw new Error("gateway unreachable");
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("isTrustCheckedScopeProfile", () => {
|
|
77
|
+
test("an exempt profile is not trust-checked", () => {
|
|
78
|
+
for (const profile of TRUST_EXEMPT_SCOPE_PROFILES) {
|
|
79
|
+
expect(isTrustCheckedScopeProfile(profile)).toBe(false);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the contact profile, unknown profiles and prototype keys are", () => {
|
|
84
|
+
for (const profile of [
|
|
85
|
+
"contact_client_v1",
|
|
86
|
+
"bogus_v1",
|
|
87
|
+
"toString",
|
|
88
|
+
"constructor",
|
|
89
|
+
"__proto__",
|
|
90
|
+
]) {
|
|
91
|
+
expect(isTrustCheckedScopeProfile(profile)).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("tokenMayReachRoute for a trust-exempt token", () => {
|
|
97
|
+
test.each([...TRUST_EXEMPT_SCOPE_PROFILES])(
|
|
98
|
+
"%s counts as the guardian without a lookup",
|
|
99
|
+
async (profile) => {
|
|
100
|
+
const resolve = mock(async () => "trusted_contact");
|
|
101
|
+
expect(await tokenMayReachRoute(profile, undefined, resolve)).toBe(true);
|
|
102
|
+
expect(await tokenMayReachRoute(profile, CONTACT_ROUTE, resolve)).toBe(
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
expect(
|
|
106
|
+
await tokenMayReachRoute(profile, ["trusted_contact"], resolve),
|
|
107
|
+
).toBe(false);
|
|
108
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
});
|
|
@@ -56,8 +56,8 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
|
|
|
56
56
|
*
|
|
57
57
|
* `phone` is NOT exempt — voice ingress enforces the admission floor.
|
|
58
58
|
*
|
|
59
|
-
* `vellum` / `whatsapp` are NOT exempt
|
|
60
|
-
* runtime
|
|
59
|
+
* `vellum` / `vellum-shared` / `whatsapp` are NOT exempt: their floors are
|
|
60
|
+
* still enforced at runtime, but they are hidden from the configurable UI; see
|
|
61
61
|
* {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
|
|
62
62
|
*/
|
|
63
63
|
export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
|
|
@@ -81,9 +81,12 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
|
|
|
81
81
|
* `vellum` is the local desktop/web client surface; the guardian is always
|
|
82
82
|
* max-rank there, so the seed default admits them regardless of the floor.
|
|
83
83
|
*
|
|
84
|
+
* `vellum-shared` carries conversations a guardian shares with a contact. Its
|
|
85
|
+
* seed default is `guardian_only`, which admits no contact.
|
|
84
86
|
*/
|
|
85
87
|
export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
|
|
86
88
|
"vellum",
|
|
89
|
+
"vellum-shared",
|
|
87
90
|
"whatsapp",
|
|
88
91
|
]);
|
|
89
92
|
|
|
@@ -132,6 +132,15 @@ export type {
|
|
|
132
132
|
TrustVerdict,
|
|
133
133
|
} from "./trust-verdict-contract.js";
|
|
134
134
|
|
|
135
|
+
// Route trust class, applied by both the daemon router and the gateway IPC proxy
|
|
136
|
+
export {
|
|
137
|
+
DEFAULT_ROUTE_TRUST_CLASSES,
|
|
138
|
+
isTrustCheckedScopeProfile,
|
|
139
|
+
routeAdmitsTrustClass,
|
|
140
|
+
tokenMayReachRoute,
|
|
141
|
+
TRUST_EXEMPT_SCOPE_PROFILES,
|
|
142
|
+
} from "./route-trust-class.js";
|
|
143
|
+
|
|
135
144
|
// Invite contract (shared gateway ↔ daemon) — hash/generate helpers,
|
|
136
145
|
// channel gating, redemption outcome, method map + invite IPC schemas
|
|
137
146
|
export {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which trust classes a route admits, shared by the daemon's HTTP router and
|
|
3
|
+
* the gateway's IPC proxy so both enforcement points apply one rule.
|
|
4
|
+
*
|
|
5
|
+
* A route policy's `allowedTrustClasses` reaches the gateway on the IPC route
|
|
6
|
+
* schema as plain strings, and is absent from a null policy or a schema served
|
|
7
|
+
* by a daemon that predates the field. Absent means guardian only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isTrustClass, type TrustClass } from "./trust-verdict-contract.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scope profiles whose holders are not trust-checked: the guardian's own
|
|
14
|
+
* client, and service, local and single-route grants that route policy
|
|
15
|
+
* already governs. Any profile not listed here is trust-checked, so an
|
|
16
|
+
* unknown or newly added profile fails closed until it is judged exempt.
|
|
17
|
+
*/
|
|
18
|
+
export const TRUST_EXEMPT_SCOPE_PROFILES: readonly string[] = [
|
|
19
|
+
"actor_client_v1",
|
|
20
|
+
"gateway_ingress_v1",
|
|
21
|
+
"gateway_service_v1",
|
|
22
|
+
"local_v1",
|
|
23
|
+
"oauth_proxy_v1",
|
|
24
|
+
"speech_relay_v1",
|
|
25
|
+
"ui_page_v1",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const TRUST_EXEMPT_SET: ReadonlySet<string> = new Set(
|
|
29
|
+
TRUST_EXEMPT_SCOPE_PROFILES,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when a token of `profile` reaches a route only if the route admits
|
|
34
|
+
* its holder's trust class.
|
|
35
|
+
*/
|
|
36
|
+
export function isTrustCheckedScopeProfile(profile: string): boolean {
|
|
37
|
+
return !TRUST_EXEMPT_SET.has(profile);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Trust classes a route admits when its policy names none. */
|
|
41
|
+
export const DEFAULT_ROUTE_TRUST_CLASSES: readonly TrustClass[] = ["guardian"];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a route admitting `allowedTrustClasses` admits a caller of
|
|
45
|
+
* `trustClass`. A value outside the vocabulary is refused, since a wire-sourced
|
|
46
|
+
* class can carry anything.
|
|
47
|
+
*/
|
|
48
|
+
export function routeAdmitsTrustClass(
|
|
49
|
+
allowedTrustClasses: readonly string[] | undefined,
|
|
50
|
+
trustClass: string | undefined,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (typeof trustClass !== "string" || !isTrustClass(trustClass)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return (allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES).includes(
|
|
56
|
+
trustClass,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a caller whose token carries `scopeProfile` may reach a route
|
|
62
|
+
* admitting `allowedTrustClasses`.
|
|
63
|
+
*
|
|
64
|
+
* A trust-exempt profile stands for the guardian, so it reaches exactly the
|
|
65
|
+
* routes that admit `guardian`, with no lookup. That is every route naming no
|
|
66
|
+
* classes.
|
|
67
|
+
*
|
|
68
|
+
* A trust-checked token never speaks for the guardian, so a route admitting no
|
|
69
|
+
* other class refuses it without calling `resolveTrustClass`. Otherwise the
|
|
70
|
+
* resolved class must be one the route admits and must not be `guardian`. A
|
|
71
|
+
* resolver that throws or resolves nothing refuses.
|
|
72
|
+
*/
|
|
73
|
+
export async function tokenMayReachRoute(
|
|
74
|
+
scopeProfile: string,
|
|
75
|
+
allowedTrustClasses: readonly string[] | undefined,
|
|
76
|
+
resolveTrustClass: () => Promise<string | undefined>,
|
|
77
|
+
): Promise<boolean> {
|
|
78
|
+
if (!isTrustCheckedScopeProfile(scopeProfile)) {
|
|
79
|
+
return routeAdmitsTrustClass(allowedTrustClasses, "guardian");
|
|
80
|
+
}
|
|
81
|
+
const allowed = allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES;
|
|
82
|
+
if (!allowed.some((trustClass) => trustClass !== "guardian")) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
let trustClass: string | undefined;
|
|
86
|
+
try {
|
|
87
|
+
trustClass = await resolveTrustClass();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
trustClass !== "guardian" && routeAdmitsTrustClass(allowed, trustClass)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
|
|
|
24
24
|
// so these must remain canonical for that assertion to mean anything.
|
|
25
25
|
expect(isChannelId("platform")).toBe(true);
|
|
26
26
|
expect(isChannelId("vellum")).toBe(true);
|
|
27
|
+
expect(isChannelId("vellum-shared")).toBe(true);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test("includes discord", () => {
|
|
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
|
|
|
58
59
|
test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
|
|
59
60
|
expect(botProviderForChannel("phone")).toBeUndefined();
|
|
60
61
|
expect(botProviderForChannel("vellum")).toBeUndefined();
|
|
62
|
+
expect(botProviderForChannel("vellum-shared")).toBeUndefined();
|
|
61
63
|
expect(botProviderForChannel("not-a-channel")).toBeUndefined();
|
|
62
64
|
// `slack` is the person's integration standing beside the bot's key.
|
|
63
65
|
expect(channelForBotProvider("slack")).toBeUndefined();
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A "channel" is an external messaging surface an actor can reach the
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
|
-
* internal ids (`vellum` for native app conversations, `
|
|
8
|
-
*
|
|
7
|
+
* internal ids (`vellum` for native app conversations, `vellum-shared` for
|
|
8
|
+
* conversations a guardian shares with a contact, `platform` for the internal
|
|
9
|
+
* control plane). This is the single source of truth for that set:
|
|
9
10
|
*
|
|
10
11
|
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
12
|
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
|
|
|
41
42
|
"a2a",
|
|
42
43
|
"discord",
|
|
43
44
|
"plugin",
|
|
45
|
+
// Internal id for conversations a guardian shares with a contact. It has no
|
|
46
|
+
// transport: the contact's own client holds the connection.
|
|
47
|
+
"vellum-shared",
|
|
44
48
|
] as const;
|
|
45
49
|
|
|
46
50
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
78
82
|
* here would be a second copy of a different fact.
|
|
79
83
|
*
|
|
80
84
|
* Channels absent from this map reach the assistant without a bot credential
|
|
81
|
-
* of their own: `phone` through the voice provider, `vellum
|
|
82
|
-
* internally.
|
|
85
|
+
* of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
|
|
86
|
+
* and `platform` internally.
|
|
83
87
|
*/
|
|
84
88
|
export const CHANNEL_BOT_PROVIDER = {
|
|
85
89
|
slack: "slack_channel",
|
package/package.json
CHANGED
|
@@ -133,6 +133,7 @@ describe("GET /v1/channel-admission-policy", () => {
|
|
|
133
133
|
store.set("a2a", "guardian_only");
|
|
134
134
|
store.set("vellum", "guardian_only");
|
|
135
135
|
store.set("whatsapp", "guardian_only");
|
|
136
|
+
store.set("vellum-shared", "guardian_only");
|
|
136
137
|
|
|
137
138
|
const handler = createChannelAdmissionPolicyListHandler();
|
|
138
139
|
const res = await handler(
|
|
@@ -145,6 +146,7 @@ describe("GET /v1/channel-admission-policy", () => {
|
|
|
145
146
|
expect(seen.has("a2a")).toBe(false);
|
|
146
147
|
expect(seen.has("vellum")).toBe(false);
|
|
147
148
|
expect(seen.has("whatsapp")).toBe(false);
|
|
149
|
+
expect(seen.has("vellum-shared")).toBe(false);
|
|
148
150
|
// phone is enforced + visible; other visible channels still surface.
|
|
149
151
|
expect(seen.has("phone")).toBe(true);
|
|
150
152
|
expect(seen.has("telegram")).toBe(true);
|
|
@@ -161,11 +163,10 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
|
|
|
161
163
|
test("upserts a valid policy and invalidates the cache", async () => {
|
|
162
164
|
const handler = createChannelAdmissionPolicySetHandler();
|
|
163
165
|
const res = await handler(
|
|
164
|
-
jsonRequest(
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
),
|
|
166
|
+
jsonRequest("http://localhost/v1/channel-admission-policy/slack", "PUT", {
|
|
167
|
+
policy: "guardian_only",
|
|
168
|
+
note: "tight",
|
|
169
|
+
}),
|
|
169
170
|
"slack",
|
|
170
171
|
);
|
|
171
172
|
expect(res.status).toBe(200);
|
|
@@ -199,11 +200,9 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
|
|
|
199
200
|
test("rejects invalid policy with 400", async () => {
|
|
200
201
|
const handler = createChannelAdmissionPolicySetHandler();
|
|
201
202
|
const res = await handler(
|
|
202
|
-
jsonRequest(
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
{ policy: "lets-everyone-in" },
|
|
206
|
-
),
|
|
203
|
+
jsonRequest("http://localhost/v1/channel-admission-policy/email", "PUT", {
|
|
204
|
+
policy: "lets-everyone-in",
|
|
205
|
+
}),
|
|
207
206
|
"email",
|
|
208
207
|
);
|
|
209
208
|
expect(res.status).toBe(400);
|
|
@@ -242,11 +241,11 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
|
|
|
242
241
|
expect(body.policy.note).toBeNull();
|
|
243
242
|
});
|
|
244
243
|
|
|
245
|
-
test("rejects PUT for hidden channels (`vellum`, `whatsapp`) with 403", async () => {
|
|
244
|
+
test("rejects PUT for hidden channels (`vellum`, `vellum-shared`, `whatsapp`) with 403", async () => {
|
|
246
245
|
// Hidden channels are managed automatically and not user-configurable;
|
|
247
246
|
// even a non-kill-switch policy must be rejected so no stranded row forms.
|
|
248
247
|
const handler = createChannelAdmissionPolicySetHandler();
|
|
249
|
-
for (const channel of ["vellum", "whatsapp"] as const) {
|
|
248
|
+
for (const channel of ["vellum", "vellum-shared", "whatsapp"] as const) {
|
|
250
249
|
const res = await handler(
|
|
251
250
|
jsonRequest(
|
|
252
251
|
`http://localhost/v1/channel-admission-policy/${channel}`,
|
|
@@ -298,7 +297,10 @@ describe("DELETE /v1/channel-admission-policy/:channelType", () => {
|
|
|
298
297
|
store.set("slack", "guardian_only");
|
|
299
298
|
const handler = createChannelAdmissionPolicyDeleteHandler();
|
|
300
299
|
const res = await handler(
|
|
301
|
-
jsonRequest(
|
|
300
|
+
jsonRequest(
|
|
301
|
+
"http://localhost/v1/channel-admission-policy/slack",
|
|
302
|
+
"DELETE",
|
|
303
|
+
),
|
|
302
304
|
"slack",
|
|
303
305
|
);
|
|
304
306
|
expect(res.status).toBe(200);
|
|
@@ -335,7 +337,9 @@ describe("GET /v1/channel-admission-policy — phone enforced", () => {
|
|
|
335
337
|
jsonRequest("http://localhost/v1/channel-admission-policy", "GET"),
|
|
336
338
|
);
|
|
337
339
|
expect(res.status).toBe(200);
|
|
338
|
-
const body = (await res.json()) as {
|
|
340
|
+
const body = (await res.json()) as {
|
|
341
|
+
policies: Array<{ channelType: string }>;
|
|
342
|
+
};
|
|
339
343
|
const seen = new Set(body.policies.map((p) => p.channelType));
|
|
340
344
|
expect(seen.has("phone")).toBe(true);
|
|
341
345
|
// Confirm other non-exempt channels still appear.
|