@vellumai/assistant 0.4.5 → 0.4.7
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 +27 -10
- package/README.md +6 -6
- package/bun.lock +57 -2
- package/docs/architecture/memory.md +4 -4
- package/docs/trusted-contact-access.md +8 -0
- package/package.json +3 -2
- package/src/__tests__/actor-token-service.test.ts +9 -6
- package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/call-controller.test.ts +115 -0
- package/src/__tests__/call-domain.test.ts +148 -10
- package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
- package/src/__tests__/call-pointer-messages.test.ts +105 -43
- package/src/__tests__/canonical-guardian-store.test.ts +44 -10
- package/src/__tests__/channel-approval-routes.test.ts +67 -65
- package/src/__tests__/channel-delivery-store.test.ts +2 -2
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
- package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
- package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
- package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
- package/src/__tests__/guardian-dispatch.test.ts +39 -1
- package/src/__tests__/guardian-grant-minting.test.ts +24 -24
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
- package/src/__tests__/guardian-routing-state.test.ts +10 -32
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
- package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
- package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +57 -47
- package/src/__tests__/notification-decision-fallback.test.ts +232 -0
- package/src/__tests__/notification-decision-strategy.test.ts +304 -8
- package/src/__tests__/notification-guardian-path.test.ts +38 -1
- package/src/__tests__/relay-server.test.ts +136 -5
- package/src/__tests__/send-endpoint-busy.test.ts +35 -1
- package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
- package/src/__tests__/system-prompt.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +1 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +10 -2
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +14 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +24 -24
- package/src/__tests__/trusted-contact-multichannel.test.ts +5 -5
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/approvals/guardian-decision-primitive.ts +29 -25
- package/src/approvals/guardian-request-resolvers.ts +9 -5
- package/src/calls/call-controller.ts +15 -0
- package/src/calls/call-pointer-message-composer.ts +27 -85
- package/src/calls/call-pointer-messages.ts +54 -21
- package/src/calls/guardian-dispatch.ts +30 -0
- package/src/calls/relay-server.ts +58 -24
- package/src/calls/types.ts +1 -0
- package/src/config/system-prompt.ts +10 -3
- package/src/config/templates/BOOTSTRAP.md +6 -5
- package/src/config/templates/USER.md +1 -0
- package/src/config/user-reference.ts +44 -0
- package/src/daemon/handlers/guardian-actions.ts +5 -2
- package/src/daemon/handlers/sessions.ts +8 -3
- package/src/daemon/lifecycle.ts +109 -3
- package/src/daemon/providers-setup.ts +0 -8
- package/src/daemon/server.ts +32 -24
- package/src/daemon/session-agent-loop.ts +4 -3
- package/src/daemon/session-lifecycle.ts +1 -9
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -0
- package/src/daemon/session-slash.ts +35 -2
- package/src/daemon/session-tool-setup.ts +10 -0
- package/src/daemon/session.ts +1 -0
- package/src/memory/canonical-guardian-store.ts +40 -0
- package/src/memory/conversation-crud.ts +26 -0
- package/src/memory/conversation-store.ts +1 -0
- package/src/memory/db-init.ts +12 -0
- package/src/memory/guardian-bindings.ts +4 -0
- package/src/memory/job-handlers/backfill.ts +2 -9
- package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
- package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
- package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema.ts +22 -0
- package/src/notifications/README.md +8 -1
- package/src/notifications/copy-composer.ts +160 -30
- package/src/notifications/decision-engine.ts +98 -1
- package/src/runtime/access-request-helper.ts +43 -28
- package/src/runtime/actor-refresh-token-service.ts +309 -0
- package/src/runtime/actor-refresh-token-store.ts +157 -0
- package/src/runtime/actor-token-service.ts +3 -3
- package/src/runtime/actor-trust-resolver.ts +19 -14
- package/src/runtime/channel-guardian-service.ts +6 -0
- package/src/runtime/gateway-client.ts +239 -0
- package/src/runtime/guardian-context-resolver.ts +6 -2
- package/src/runtime/guardian-reply-router.ts +33 -16
- package/src/runtime/guardian-vellum-migration.ts +29 -5
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/http-types.ts +0 -13
- package/src/runtime/local-actor-identity.ts +19 -13
- package/src/runtime/middleware/actor-token.ts +2 -2
- package/src/runtime/routes/channel-delivery-routes.ts +5 -5
- package/src/runtime/routes/conversation-routes.ts +45 -35
- package/src/runtime/routes/guardian-action-routes.ts +7 -1
- package/src/runtime/routes/guardian-approval-interception.ts +52 -52
- package/src/runtime/routes/guardian-bootstrap-routes.ts +11 -24
- package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
- package/src/runtime/routes/inbound-conversation.ts +7 -7
- package/src/runtime/routes/inbound-message-handler.ts +105 -94
- package/src/runtime/routes/pairing-routes.ts +60 -50
- package/src/runtime/tool-grant-request-helper.ts +1 -0
- package/src/types/qrcode.d.ts +10 -0
- package/src/util/logger.ts +10 -0
- package/src/daemon/call-pointer-generators.ts +0 -59
|
@@ -29,16 +29,9 @@ type ProvenanceTrustClass = 'guardian' | 'trusted_contact' | 'unknown';
|
|
|
29
29
|
function parseProvenanceTrustClass(rawMetadata: string | null): ProvenanceTrustClass | undefined {
|
|
30
30
|
if (!rawMetadata) return undefined;
|
|
31
31
|
try {
|
|
32
|
-
const
|
|
33
|
-
const parsed = messageMetadataSchema.safeParse(parsedJson);
|
|
32
|
+
const parsed = messageMetadataSchema.safeParse(JSON.parse(rawMetadata));
|
|
34
33
|
if (!parsed.success) return undefined;
|
|
35
|
-
|
|
36
|
-
// Legacy fallback for rows written before provenanceTrustClass existed.
|
|
37
|
-
const legacyRole = (parsedJson as { provenanceActorRole?: unknown }).provenanceActorRole;
|
|
38
|
-
if (legacyRole === 'guardian') return 'guardian';
|
|
39
|
-
if (legacyRole === 'non-guardian') return 'trusted_contact';
|
|
40
|
-
if (legacyRole === 'unverified_channel') return 'unknown';
|
|
41
|
-
return undefined;
|
|
34
|
+
return parsed.data.provenanceTrustClass;
|
|
42
35
|
} catch {
|
|
43
36
|
return undefined;
|
|
44
37
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DrizzleDb } from '../db-connection.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create the actor_refresh_token_records table for hash-only refresh token persistence.
|
|
5
|
+
*
|
|
6
|
+
* Stores the SHA-256 hash of each refresh token with family tracking,
|
|
7
|
+
* device binding, and dual expiry (absolute + inactivity).
|
|
8
|
+
* The raw token plaintext is never stored.
|
|
9
|
+
*/
|
|
10
|
+
export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
|
|
11
|
+
database.run(/*sql*/ `
|
|
12
|
+
CREATE TABLE IF NOT EXISTS actor_refresh_token_records (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
token_hash TEXT NOT NULL,
|
|
15
|
+
family_id TEXT NOT NULL,
|
|
16
|
+
assistant_id TEXT NOT NULL,
|
|
17
|
+
guardian_principal_id TEXT NOT NULL,
|
|
18
|
+
hashed_device_id TEXT NOT NULL,
|
|
19
|
+
platform TEXT NOT NULL,
|
|
20
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
21
|
+
issued_at INTEGER NOT NULL,
|
|
22
|
+
absolute_expires_at INTEGER NOT NULL,
|
|
23
|
+
inactivity_expires_at INTEGER NOT NULL,
|
|
24
|
+
last_used_at INTEGER,
|
|
25
|
+
created_at INTEGER NOT NULL,
|
|
26
|
+
updated_at INTEGER NOT NULL
|
|
27
|
+
)
|
|
28
|
+
`);
|
|
29
|
+
|
|
30
|
+
// Token hash lookup (any status — needed for replay detection)
|
|
31
|
+
database.run(
|
|
32
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
|
|
33
|
+
ON actor_refresh_token_records(token_hash)`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Unique active refresh token per device binding.
|
|
37
|
+
// DROP first so that databases that already created the older non-unique
|
|
38
|
+
// index with the same name get upgraded to UNIQUE.
|
|
39
|
+
database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
|
|
40
|
+
database.run(
|
|
41
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
|
|
42
|
+
ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
|
|
43
|
+
WHERE status = 'active'`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Family lookup for replay detection (revoke entire family)
|
|
47
|
+
database.run(
|
|
48
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
|
|
49
|
+
ON actor_refresh_token_records(family_id)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DrizzleDb } from '../db-connection.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Add guardian_principal_id columns to channel_guardian_bindings and
|
|
5
|
+
* canonical_guardian_requests, plus decided_by_principal_id to
|
|
6
|
+
* canonical_guardian_requests.
|
|
7
|
+
*
|
|
8
|
+
* These nullable TEXT columns support the canonical identity binding
|
|
9
|
+
* cutover — linking guardian bindings and approval requests to a
|
|
10
|
+
* stable principal identity rather than relying solely on
|
|
11
|
+
* channel-specific external user IDs.
|
|
12
|
+
*
|
|
13
|
+
* Uses ALTER TABLE ADD COLUMN with try/catch for idempotency.
|
|
14
|
+
*/
|
|
15
|
+
export function migrateGuardianPrincipalIdColumns(database: DrizzleDb): void {
|
|
16
|
+
try { database.run(/*sql*/ `ALTER TABLE channel_guardian_bindings ADD COLUMN guardian_principal_id TEXT`); } catch { /* already exists */ }
|
|
17
|
+
try { database.run(/*sql*/ `ALTER TABLE canonical_guardian_requests ADD COLUMN guardian_principal_id TEXT`); } catch { /* already exists */ }
|
|
18
|
+
try { database.run(/*sql*/ `ALTER TABLE canonical_guardian_requests ADD COLUMN decided_by_principal_id TEXT`); } catch { /* already exists */ }
|
|
19
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
|
|
2
|
+
import { withCrashRecovery } from './validate-migration-state.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Backfill guardianPrincipalId for existing channel_guardian_bindings and
|
|
6
|
+
* canonical_guardian_requests rows.
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
*
|
|
10
|
+
* 1. Derive the assistant's canonical principal from the active 'vellum'
|
|
11
|
+
* binding's guardianExternalUserId. This is the stable identity used for
|
|
12
|
+
* all guardian decisions from the desktop client.
|
|
13
|
+
*
|
|
14
|
+
* 2. Backfill channel_guardian_bindings:
|
|
15
|
+
* a. For the vellum binding: set guardianPrincipalId = guardianExternalUserId
|
|
16
|
+
* (the vellum external user ID IS the canonical principal).
|
|
17
|
+
* b. For non-vellum bindings: set guardianPrincipalId to the vellum
|
|
18
|
+
* binding's principal (unifying all channels onto one canonical
|
|
19
|
+
* principal). Falls back to guardianExternalUserId if no vellum
|
|
20
|
+
* binding exists.
|
|
21
|
+
*
|
|
22
|
+
* 3. Backfill canonical_guardian_requests (pending only):
|
|
23
|
+
* a. If the request has a guardianExternalUserId that maps to an active
|
|
24
|
+
* binding, use that binding's guardianPrincipalId (now backfilled).
|
|
25
|
+
* b. For desktop-originated requests (sourceType = 'desktop' or
|
|
26
|
+
* sourceChannel = 'vellum') that lack guardianExternalUserId,
|
|
27
|
+
* use the assistant principal derived in step 1.
|
|
28
|
+
* c. Pending requests that cannot be deterministically bound are expired
|
|
29
|
+
* (including access_request rows, which are now principal-bound).
|
|
30
|
+
*
|
|
31
|
+
* 4. Idempotent: uses checkpoint key + only updates rows with NULL
|
|
32
|
+
* guardianPrincipalId.
|
|
33
|
+
*/
|
|
34
|
+
export function migrateBackfillGuardianPrincipalId(database: DrizzleDb): void {
|
|
35
|
+
withCrashRecovery(database, 'migration_backfill_guardian_principal_id_v3', () => {
|
|
36
|
+
const raw = getSqliteFrom(database);
|
|
37
|
+
|
|
38
|
+
// Guard: tables must exist
|
|
39
|
+
const bindingsTableExists = raw.query(
|
|
40
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
|
|
41
|
+
).get();
|
|
42
|
+
if (!bindingsTableExists) return;
|
|
43
|
+
|
|
44
|
+
const requestsTableExists = raw.query(
|
|
45
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'canonical_guardian_requests'`,
|
|
46
|
+
).get();
|
|
47
|
+
|
|
48
|
+
// Guard: guardian_principal_id column must exist on bindings table
|
|
49
|
+
const bindingColExists = raw.query(
|
|
50
|
+
`SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
|
|
51
|
+
).get();
|
|
52
|
+
if (!bindingColExists) return;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
raw.exec('BEGIN');
|
|
56
|
+
|
|
57
|
+
// ── Step 1a: Backfill vellum binding first ─────────────────────
|
|
58
|
+
// The vellum binding's external user ID IS the canonical principal.
|
|
59
|
+
raw.exec(/*sql*/ `
|
|
60
|
+
UPDATE channel_guardian_bindings
|
|
61
|
+
SET guardian_principal_id = guardian_external_user_id,
|
|
62
|
+
updated_at = ${Date.now()}
|
|
63
|
+
WHERE status = 'active'
|
|
64
|
+
AND channel = 'vellum'
|
|
65
|
+
AND guardian_external_user_id IS NOT NULL
|
|
66
|
+
AND guardian_principal_id IS NULL
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
// ── Step 1b: Derive canonical principal from vellum binding ──
|
|
70
|
+
const vellumRow = raw.query(
|
|
71
|
+
`SELECT guardian_principal_id FROM channel_guardian_bindings
|
|
72
|
+
WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active'
|
|
73
|
+
AND guardian_principal_id IS NOT NULL LIMIT 1`,
|
|
74
|
+
).get() as { guardian_principal_id: string } | null;
|
|
75
|
+
|
|
76
|
+
if (vellumRow) {
|
|
77
|
+
// Unify non-vellum bindings onto the canonical principal
|
|
78
|
+
raw.query(
|
|
79
|
+
`UPDATE channel_guardian_bindings
|
|
80
|
+
SET guardian_principal_id = ?,
|
|
81
|
+
updated_at = ${Date.now()}
|
|
82
|
+
WHERE status = 'active'
|
|
83
|
+
AND channel != 'vellum'
|
|
84
|
+
AND guardian_external_user_id IS NOT NULL
|
|
85
|
+
AND guardian_principal_id IS NULL`,
|
|
86
|
+
).run(vellumRow.guardian_principal_id);
|
|
87
|
+
} else {
|
|
88
|
+
// No vellum binding — fallback to channel-specific principal
|
|
89
|
+
raw.exec(/*sql*/ `
|
|
90
|
+
UPDATE channel_guardian_bindings
|
|
91
|
+
SET guardian_principal_id = guardian_external_user_id,
|
|
92
|
+
updated_at = ${Date.now()}
|
|
93
|
+
WHERE status = 'active'
|
|
94
|
+
AND guardian_external_user_id IS NOT NULL
|
|
95
|
+
AND guardian_principal_id IS NULL
|
|
96
|
+
`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Step 2: Derive assistant principal from vellum binding ─────
|
|
100
|
+
// The vellum binding's guardianExternalUserId is the canonical
|
|
101
|
+
// assistant principal used for desktop-originated requests.
|
|
102
|
+
const vellumBinding = raw.query(
|
|
103
|
+
`SELECT guardian_external_user_id, guardian_principal_id FROM channel_guardian_bindings WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active' LIMIT 1`,
|
|
104
|
+
).get() as { guardian_external_user_id: string; guardian_principal_id: string | null } | null;
|
|
105
|
+
|
|
106
|
+
// Use the (now-backfilled) principal from the vellum binding
|
|
107
|
+
const assistantPrincipal = vellumBinding?.guardian_principal_id ?? vellumBinding?.guardian_external_user_id ?? null;
|
|
108
|
+
|
|
109
|
+
// ── Step 3: Backfill canonical_guardian_requests ────────────────
|
|
110
|
+
if (requestsTableExists) {
|
|
111
|
+
const requestColExists = raw.query(
|
|
112
|
+
`SELECT 1 FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'guardian_principal_id'`,
|
|
113
|
+
).get();
|
|
114
|
+
|
|
115
|
+
if (requestColExists) {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
|
|
118
|
+
// 3a. Pending requests with a guardianExternalUserId that maps
|
|
119
|
+
// to an active binding — use the binding's principal.
|
|
120
|
+
// Includes all kinds; binding is always useful when available.
|
|
121
|
+
const pendingWithGuardian = raw.query(
|
|
122
|
+
`SELECT r.id, r.guardian_external_user_id, r.kind
|
|
123
|
+
FROM canonical_guardian_requests r
|
|
124
|
+
WHERE r.status = 'pending'
|
|
125
|
+
AND r.guardian_principal_id IS NULL
|
|
126
|
+
AND r.guardian_external_user_id IS NOT NULL`,
|
|
127
|
+
).all() as Array<{ id: string; guardian_external_user_id: string; kind: string }>;
|
|
128
|
+
|
|
129
|
+
// Build a lookup of guardianExternalUserId -> principalId from
|
|
130
|
+
// active bindings (all already backfilled in step 1).
|
|
131
|
+
const activeBindings = raw.query(
|
|
132
|
+
`SELECT guardian_external_user_id, guardian_principal_id
|
|
133
|
+
FROM channel_guardian_bindings
|
|
134
|
+
WHERE assistant_id = 'self' AND status = 'active' AND guardian_principal_id IS NOT NULL`,
|
|
135
|
+
).all() as Array<{ guardian_external_user_id: string; guardian_principal_id: string }>;
|
|
136
|
+
|
|
137
|
+
const externalToP = new Map<string, string>();
|
|
138
|
+
for (const b of activeBindings) {
|
|
139
|
+
externalToP.set(b.guardian_external_user_id, b.guardian_principal_id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const updateStmt = raw.prepare(
|
|
143
|
+
`UPDATE canonical_guardian_requests SET guardian_principal_id = ?, updated_at = ? WHERE id = ?`,
|
|
144
|
+
);
|
|
145
|
+
const expireStmt = raw.prepare(
|
|
146
|
+
`UPDATE canonical_guardian_requests SET status = 'expired', updated_at = ? WHERE id = ?`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const unboundRequestIds: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const req of pendingWithGuardian) {
|
|
152
|
+
const principal = externalToP.get(req.guardian_external_user_id);
|
|
153
|
+
if (principal) {
|
|
154
|
+
updateStmt.run(principal, now, req.id);
|
|
155
|
+
} else {
|
|
156
|
+
// Cannot deterministically map — will expire below.
|
|
157
|
+
unboundRequestIds.push(req.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3b. Desktop-originated pending requests missing guardian info
|
|
162
|
+
// entirely — bind to the assistant principal. Only applies to
|
|
163
|
+
// requests that also lack a guardian external user ID; requests
|
|
164
|
+
// that carry an external ID but failed step 3a mapping should be
|
|
165
|
+
// expired, not reassigned.
|
|
166
|
+
if (assistantPrincipal) {
|
|
167
|
+
raw.query(
|
|
168
|
+
`UPDATE canonical_guardian_requests
|
|
169
|
+
SET guardian_principal_id = ?, updated_at = ?
|
|
170
|
+
WHERE status = 'pending'
|
|
171
|
+
AND guardian_principal_id IS NULL
|
|
172
|
+
AND guardian_external_user_id IS NULL
|
|
173
|
+
AND (source_type = 'desktop' OR source_channel = 'vellum')`,
|
|
174
|
+
).run(assistantPrincipal, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3c. Expire remaining pending requests that still have no
|
|
178
|
+
// guardian_principal_id. These requests can never be approved
|
|
179
|
+
// in the principal-based system, so they must be expired
|
|
180
|
+
// proactively. This includes access_request rows which are
|
|
181
|
+
// now decisionable and principal-bound.
|
|
182
|
+
const stillUnbound = raw.query(
|
|
183
|
+
`SELECT id FROM canonical_guardian_requests
|
|
184
|
+
WHERE guardian_principal_id IS NULL
|
|
185
|
+
AND status = 'pending'`,
|
|
186
|
+
).all() as Array<{ id: string }>;
|
|
187
|
+
|
|
188
|
+
for (const req of stillUnbound) {
|
|
189
|
+
expireStmt.run(now, req.id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Also expire requests identified in 3a that had no binding match.
|
|
193
|
+
for (const id of unboundRequestIds) {
|
|
194
|
+
const check = raw.query(
|
|
195
|
+
`SELECT guardian_principal_id FROM canonical_guardian_requests WHERE id = ? AND status = 'pending'`,
|
|
196
|
+
).get(id) as { guardian_principal_id: string | null } | null;
|
|
197
|
+
if (check && !check.guardian_principal_id) {
|
|
198
|
+
expireStmt.run(now, id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
raw.exec('COMMIT');
|
|
205
|
+
} catch (e) {
|
|
206
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -40,6 +40,7 @@ export { migrateGuardianActionSupersession } from './035-guardian-action-superse
|
|
|
40
40
|
export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
|
|
41
41
|
export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
|
|
42
42
|
export { createActorTokenRecordsTable } from './038-actor-token-records.js';
|
|
43
|
+
export { createActorRefreshTokenRecordsTable } from './039-actor-refresh-token-records.js';
|
|
43
44
|
export { createCoreTables } from './100-core-tables.js';
|
|
44
45
|
export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
|
|
45
46
|
export { addCoreColumns } from './102-alter-table-columns.js';
|
|
@@ -65,6 +66,8 @@ export { createCanonicalGuardianTables } from './121-canonical-guardian-requests
|
|
|
65
66
|
export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
|
|
66
67
|
export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
|
|
67
68
|
export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
|
|
69
|
+
export { migrateGuardianPrincipalIdColumns } from './125-guardian-principal-id-columns.js';
|
|
70
|
+
export { migrateBackfillGuardianPrincipalId } from './126-backfill-guardian-principal-id.js';
|
|
68
71
|
export {
|
|
69
72
|
MIGRATION_REGISTRY,
|
|
70
73
|
type MigrationRegistryEntry,
|
|
@@ -95,6 +95,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
|
|
|
95
95
|
version: 14,
|
|
96
96
|
description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
key: 'migration_backfill_guardian_principal_id_v3',
|
|
100
|
+
version: 15,
|
|
101
|
+
description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
|
|
102
|
+
},
|
|
98
103
|
];
|
|
99
104
|
|
|
100
105
|
export interface MigrationValidationResult {
|
package/src/memory/schema.ts
CHANGED
|
@@ -637,6 +637,7 @@ export const channelGuardianBindings = sqliteTable('channel_guardian_bindings',
|
|
|
637
637
|
channel: text('channel').notNull(),
|
|
638
638
|
guardianExternalUserId: text('guardian_external_user_id').notNull(),
|
|
639
639
|
guardianDeliveryChatId: text('guardian_delivery_chat_id').notNull(),
|
|
640
|
+
guardianPrincipalId: text('guardian_principal_id'),
|
|
640
641
|
status: text('status').notNull().default('active'),
|
|
641
642
|
verifiedAt: integer('verified_at').notNull(),
|
|
642
643
|
verifiedVia: text('verified_via').notNull().default('challenge'),
|
|
@@ -887,6 +888,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
|
|
|
887
888
|
requesterExternalUserId: text('requester_external_user_id'),
|
|
888
889
|
requesterChatId: text('requester_chat_id'),
|
|
889
890
|
guardianExternalUserId: text('guardian_external_user_id'),
|
|
891
|
+
guardianPrincipalId: text('guardian_principal_id'),
|
|
890
892
|
callSessionId: text('call_session_id'),
|
|
891
893
|
pendingQuestionId: text('pending_question_id'),
|
|
892
894
|
questionText: text('question_text'),
|
|
@@ -896,6 +898,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
|
|
|
896
898
|
status: text('status').notNull().default('pending'),
|
|
897
899
|
answerText: text('answer_text'),
|
|
898
900
|
decidedByExternalUserId: text('decided_by_external_user_id'),
|
|
901
|
+
decidedByPrincipalId: text('decided_by_principal_id'),
|
|
899
902
|
followupState: text('followup_state'),
|
|
900
903
|
expiresAt: text('expires_at'),
|
|
901
904
|
createdAt: text('created_at').notNull(),
|
|
@@ -1159,6 +1162,25 @@ export const actorTokenRecords = sqliteTable('actor_token_records', {
|
|
|
1159
1162
|
updatedAt: integer('updated_at').notNull(),
|
|
1160
1163
|
});
|
|
1161
1164
|
|
|
1165
|
+
// ── Actor Refresh Token Records ──────────────────────────────────────
|
|
1166
|
+
|
|
1167
|
+
export const actorRefreshTokenRecords = sqliteTable('actor_refresh_token_records', {
|
|
1168
|
+
id: text('id').primaryKey(),
|
|
1169
|
+
tokenHash: text('token_hash').notNull(),
|
|
1170
|
+
familyId: text('family_id').notNull(),
|
|
1171
|
+
assistantId: text('assistant_id').notNull(),
|
|
1172
|
+
guardianPrincipalId: text('guardian_principal_id').notNull(),
|
|
1173
|
+
hashedDeviceId: text('hashed_device_id').notNull(),
|
|
1174
|
+
platform: text('platform').notNull(),
|
|
1175
|
+
status: text('status').notNull().default('active'),
|
|
1176
|
+
issuedAt: integer('issued_at').notNull(),
|
|
1177
|
+
absoluteExpiresAt: integer('absolute_expires_at').notNull(),
|
|
1178
|
+
inactivityExpiresAt: integer('inactivity_expires_at').notNull(),
|
|
1179
|
+
lastUsedAt: integer('last_used_at'),
|
|
1180
|
+
createdAt: integer('created_at').notNull(),
|
|
1181
|
+
updatedAt: integer('updated_at').notNull(),
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1162
1184
|
// ── Scoped Approval Grants ──────────────────────────────────────────
|
|
1163
1185
|
|
|
1164
1186
|
export const scopedApprovalGrants = sqliteTable('scoped_approval_grants', {
|
|
@@ -51,7 +51,14 @@ When the LLM is unavailable or returns invalid output, a deterministic fallback
|
|
|
51
51
|
|
|
52
52
|
### 4. Deterministic Checks
|
|
53
53
|
|
|
54
|
-
Hard invariants that the LLM cannot override
|
|
54
|
+
Hard invariants that the LLM cannot override:
|
|
55
|
+
|
|
56
|
+
**Post-generation enforcement** (`decision-engine.ts`):
|
|
57
|
+
|
|
58
|
+
- **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them.
|
|
59
|
+
- **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
|
|
60
|
+
|
|
61
|
+
**Pre-send gate checks** (`deterministic-checks.ts`):
|
|
55
62
|
|
|
56
63
|
- **Schema validity** -- fail-closed if the decision is malformed
|
|
57
64
|
- **Source-active suppression** -- if the user is already viewing the source context, suppress
|
|
@@ -29,6 +29,162 @@ export function nonEmpty(value: string | undefined): string | undefined {
|
|
|
29
29
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// ── Access-request copy contract ─────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// Deterministic helpers for building guardian-facing access-request copy.
|
|
35
|
+
// These are used both by the fallback template and the decision-engine
|
|
36
|
+
// post-generation enforcement to ensure required directives always appear.
|
|
37
|
+
|
|
38
|
+
const IDENTITY_FIELD_MAX_LENGTH = 120;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sanitize an untrusted identity field for inclusion in notification copy.
|
|
42
|
+
*
|
|
43
|
+
* - Strips control characters (U+0000–U+001F, U+007F–U+009F) and newlines.
|
|
44
|
+
* - Clamps to IDENTITY_FIELD_MAX_LENGTH characters.
|
|
45
|
+
* - Wraps in quotes to neutralize instruction-like payload text.
|
|
46
|
+
*/
|
|
47
|
+
export function sanitizeIdentityField(value: string): string {
|
|
48
|
+
const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, ' ').trim();
|
|
49
|
+
const clamped = stripped.length > IDENTITY_FIELD_MAX_LENGTH
|
|
50
|
+
? stripped.slice(0, IDENTITY_FIELD_MAX_LENGTH) + '…'
|
|
51
|
+
: stripped;
|
|
52
|
+
return clamped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildAccessRequestIdentityLine(payload: Record<string, unknown>): string {
|
|
56
|
+
const requester = sanitizeIdentityField(str(payload.senderIdentifier, 'Someone'));
|
|
57
|
+
const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
|
|
58
|
+
const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
|
|
59
|
+
const actorUsername = nonEmpty(typeof payload.actorUsername === 'string' ? payload.actorUsername : undefined);
|
|
60
|
+
const actorExternalId = nonEmpty(typeof payload.actorExternalId === 'string' ? payload.actorExternalId : undefined);
|
|
61
|
+
|
|
62
|
+
if (sourceChannel === 'voice' && callerName) {
|
|
63
|
+
const safeName = sanitizeIdentityField(callerName);
|
|
64
|
+
const safeId = sanitizeIdentityField(str(payload.actorExternalId, requester));
|
|
65
|
+
return `${safeName} (${safeId}) is calling and requesting access to the assistant.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// For non-voice, include extra context when available.
|
|
69
|
+
// Sanitize before comparing to avoid deduplication failures when identity
|
|
70
|
+
// fields contain control characters that are stripped from `requester`.
|
|
71
|
+
const sanitizedUsername = actorUsername ? sanitizeIdentityField(actorUsername) : undefined;
|
|
72
|
+
const sanitizedExternalId = actorExternalId ? sanitizeIdentityField(actorExternalId) : undefined;
|
|
73
|
+
const parts = [requester];
|
|
74
|
+
if (sanitizedUsername && sanitizedUsername !== requester) {
|
|
75
|
+
parts.push(`@${sanitizedUsername}`);
|
|
76
|
+
}
|
|
77
|
+
if (sanitizedExternalId && sanitizedExternalId !== requester && sanitizedExternalId !== sanitizedUsername) {
|
|
78
|
+
parts.push(`[${sanitizedExternalId}]`);
|
|
79
|
+
}
|
|
80
|
+
if (sourceChannel) {
|
|
81
|
+
parts.push(`via ${sourceChannel}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `${parts.join(' ')} is requesting access to the assistant.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildAccessRequestDecisionDirective(requestCode: string): string {
|
|
88
|
+
const code = requestCode.toUpperCase();
|
|
89
|
+
return `Reply "${code} approve" to grant access or "${code} reject" to deny.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildAccessRequestInviteDirective(): string {
|
|
93
|
+
return 'Reply "open invite flow" to start Trusted Contacts invite flow.';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildAccessRequestRevokedNote(): string {
|
|
97
|
+
return 'Note: this user was previously revoked.';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize text before running directive-matching regexes.
|
|
102
|
+
*
|
|
103
|
+
* - Replaces smart/curly apostrophes (\u2018, \u2019, \u201B) with ASCII `'`
|
|
104
|
+
* so contractions like "Don\u2019t" are matched by the `n't` lookbehind.
|
|
105
|
+
* - Collapses runs of whitespace into a single space so "Do not reply"
|
|
106
|
+
* is matched by the single-space negative lookbehind.
|
|
107
|
+
* - Trims leading/trailing whitespace.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeForDirectiveMatching(text: string): string {
|
|
110
|
+
return text
|
|
111
|
+
.replace(/[\u2018\u2019\u201B]/g, "'")
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check whether a text contains the required access-request instruction elements:
|
|
118
|
+
* 1. Approve directive: Reply "CODE approve"
|
|
119
|
+
* 2. Reject directive: Reply "CODE reject"
|
|
120
|
+
* 3. Invite directive: Reply "open invite flow"
|
|
121
|
+
*
|
|
122
|
+
* Each directive is matched independently using negative lookbehind to reject
|
|
123
|
+
* matches preceded by negation words ("not", "n't", "never"). This prevents
|
|
124
|
+
* contradictory copy like `Do not reply "CODE reject"` from satisfying the
|
|
125
|
+
* check even when a positive approve directive exists nearby.
|
|
126
|
+
*
|
|
127
|
+
* The text is normalized before matching to handle smart apostrophes and
|
|
128
|
+
* multiple whitespace characters that would otherwise bypass negation detection.
|
|
129
|
+
*/
|
|
130
|
+
export function hasAccessRequestInstructions(
|
|
131
|
+
text: string | undefined,
|
|
132
|
+
requestCode: string,
|
|
133
|
+
): boolean {
|
|
134
|
+
if (typeof text !== 'string') return false;
|
|
135
|
+
const normalized = normalizeForDirectiveMatching(text);
|
|
136
|
+
const escapedCode = requestCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
|
+
// Each directive must follow "reply" without a preceding negation word.
|
|
138
|
+
// Negative lookbehinds reject "do not reply", "don't reply", "never reply".
|
|
139
|
+
const approveRe = new RegExp(
|
|
140
|
+
`(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+approve"`,
|
|
141
|
+
'i',
|
|
142
|
+
);
|
|
143
|
+
const rejectRe = new RegExp(
|
|
144
|
+
`(?<!not\\s)(?<!n't\\s)(?<!never\\s)reply\\b[^.!?\\n]*?"${escapedCode}\\s+reject"`,
|
|
145
|
+
'i',
|
|
146
|
+
);
|
|
147
|
+
const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
|
|
148
|
+
|
|
149
|
+
return approveRe.test(normalized) && rejectRe.test(normalized) && inviteRe.test(normalized);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check whether text contains the invite-flow directive ("open invite flow")
|
|
154
|
+
* using the same normalized negative-lookbehind pattern as the full check.
|
|
155
|
+
* This is used for enforcement when requestCode is absent but the invite
|
|
156
|
+
* directive should still be present.
|
|
157
|
+
*/
|
|
158
|
+
export function hasInviteFlowDirective(text: string | undefined): boolean {
|
|
159
|
+
if (typeof text !== 'string') return false;
|
|
160
|
+
const normalized = normalizeForDirectiveMatching(text);
|
|
161
|
+
const inviteRe = /(?<!not\s)(?<!n't\s)(?<!never\s)reply\b[^.!?\n]*?"open invite flow"/i;
|
|
162
|
+
return inviteRe.test(normalized);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the deterministic access-request contract text from payload fields.
|
|
167
|
+
* This is the canonical baseline that enforcement can append when generated
|
|
168
|
+
* copy is missing required elements.
|
|
169
|
+
*/
|
|
170
|
+
export function buildAccessRequestContractText(payload: Record<string, unknown>): string {
|
|
171
|
+
const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
|
|
172
|
+
const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
|
|
173
|
+
? payload.previousMemberStatus
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
lines.push(buildAccessRequestIdentityLine(payload));
|
|
178
|
+
if (previousMemberStatus === 'revoked') {
|
|
179
|
+
lines.push(buildAccessRequestRevokedNote());
|
|
180
|
+
}
|
|
181
|
+
if (requestCode) {
|
|
182
|
+
lines.push(buildAccessRequestDecisionDirective(requestCode));
|
|
183
|
+
}
|
|
184
|
+
lines.push(buildAccessRequestInviteDirective());
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
32
188
|
// Templates keyed by dot-separated sourceEventName strings matching producers.
|
|
33
189
|
const TEMPLATES: Record<string, CopyTemplate> = {
|
|
34
190
|
'reminder.fired': (payload) => ({
|
|
@@ -60,36 +216,10 @@ const TEMPLATES: Record<string, CopyTemplate> = {
|
|
|
60
216
|
};
|
|
61
217
|
},
|
|
62
218
|
|
|
63
|
-
'ingress.access_request': (payload) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const callerName = nonEmpty(typeof payload.senderName === 'string' ? payload.senderName : undefined);
|
|
68
|
-
const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
|
|
69
|
-
? payload.previousMemberStatus
|
|
70
|
-
: undefined;
|
|
71
|
-
const lines: string[] = [];
|
|
72
|
-
|
|
73
|
-
// Voice-originated access requests include caller name context
|
|
74
|
-
if (sourceChannel === 'voice' && callerName) {
|
|
75
|
-
lines.push(`${callerName} (${str(payload.senderExternalUserId, requester)}) is calling and requesting access to the assistant.`);
|
|
76
|
-
} else {
|
|
77
|
-
lines.push(`${requester} is requesting access to the assistant.`);
|
|
78
|
-
}
|
|
79
|
-
if (previousMemberStatus === 'revoked') {
|
|
80
|
-
lines.push('Note: this user was previously revoked.');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (requestCode) {
|
|
84
|
-
const code = requestCode.toUpperCase();
|
|
85
|
-
lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
|
|
86
|
-
}
|
|
87
|
-
lines.push('Reply "open invite flow" to start Trusted Contacts invite flow.');
|
|
88
|
-
return {
|
|
89
|
-
title: 'Access Request',
|
|
90
|
-
body: lines.join('\n'),
|
|
91
|
-
};
|
|
92
|
-
},
|
|
219
|
+
'ingress.access_request': (payload) => ({
|
|
220
|
+
title: 'Access Request',
|
|
221
|
+
body: buildAccessRequestContractText(payload),
|
|
222
|
+
}),
|
|
93
223
|
|
|
94
224
|
'ingress.access_request.callback_handoff': (payload) => {
|
|
95
225
|
const callerName = nonEmpty(typeof payload.callerName === 'string' ? payload.callerName : undefined);
|