@vellumai/assistant 0.3.26 → 0.3.28
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 +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared access-request creation and notification helper.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the "create/dedupe canonical access request + emit notification"
|
|
5
|
+
* logic so both text-channel and voice-channel ingress paths use identical
|
|
6
|
+
* guardian notification flows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChannelId } from '../channels/types.js';
|
|
10
|
+
import {
|
|
11
|
+
createCanonicalGuardianRequest,
|
|
12
|
+
listCanonicalGuardianRequests,
|
|
13
|
+
} from '../memory/canonical-guardian-store.js';
|
|
14
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
15
|
+
import { getLogger } from '../util/logger.js';
|
|
16
|
+
import { getGuardianBinding } from './channel-guardian-service.js';
|
|
17
|
+
import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
|
|
18
|
+
|
|
19
|
+
const log = getLogger('access-request-helper');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface AccessRequestParams {
|
|
26
|
+
canonicalAssistantId: string;
|
|
27
|
+
sourceChannel: ChannelId;
|
|
28
|
+
externalChatId: string;
|
|
29
|
+
senderExternalUserId?: string;
|
|
30
|
+
senderName?: string;
|
|
31
|
+
senderUsername?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type AccessRequestResult =
|
|
35
|
+
| { notified: true; created: boolean; requestId: string }
|
|
36
|
+
| { notified: false; reason: 'no_sender_id' | 'no_guardian_binding' };
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Helper
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create/dedupe a canonical access request and emit a notification signal
|
|
44
|
+
* so the guardian can approve or deny the unknown sender.
|
|
45
|
+
*
|
|
46
|
+
* Returns a result indicating whether the guardian was notified and whether
|
|
47
|
+
* a new request was created or an existing one was deduped.
|
|
48
|
+
*
|
|
49
|
+
* This is intentionally synchronous with respect to the canonical store writes
|
|
50
|
+
* and fire-and-forget for the notification signal emission.
|
|
51
|
+
*/
|
|
52
|
+
export function notifyGuardianOfAccessRequest(
|
|
53
|
+
params: AccessRequestParams,
|
|
54
|
+
): AccessRequestResult {
|
|
55
|
+
const {
|
|
56
|
+
canonicalAssistantId,
|
|
57
|
+
sourceChannel,
|
|
58
|
+
externalChatId,
|
|
59
|
+
senderExternalUserId,
|
|
60
|
+
senderName,
|
|
61
|
+
senderUsername,
|
|
62
|
+
} = params;
|
|
63
|
+
|
|
64
|
+
if (!senderExternalUserId) {
|
|
65
|
+
return { notified: false, reason: 'no_sender_id' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
|
|
69
|
+
if (!binding) {
|
|
70
|
+
log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
|
|
71
|
+
return { notified: false, reason: 'no_guardian_binding' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Deduplicate: skip creation if there is already a pending canonical request
|
|
75
|
+
// for the same requester on this channel. Still return notified: true with
|
|
76
|
+
// the existing request ID so callers know the guardian was already notified.
|
|
77
|
+
const existingCanonical = listCanonicalGuardianRequests({
|
|
78
|
+
status: 'pending',
|
|
79
|
+
requesterExternalUserId: senderExternalUserId,
|
|
80
|
+
sourceChannel,
|
|
81
|
+
kind: 'access_request',
|
|
82
|
+
});
|
|
83
|
+
if (existingCanonical.length > 0) {
|
|
84
|
+
log.debug(
|
|
85
|
+
{ sourceChannel, senderExternalUserId, existingId: existingCanonical[0].id },
|
|
86
|
+
'Skipping duplicate access request notification',
|
|
87
|
+
);
|
|
88
|
+
return { notified: true, created: false, requestId: existingCanonical[0].id };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const senderIdentifier = senderName || senderUsername || senderExternalUserId;
|
|
92
|
+
const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
|
|
93
|
+
|
|
94
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
95
|
+
id: requestId,
|
|
96
|
+
kind: 'access_request',
|
|
97
|
+
sourceType: 'channel',
|
|
98
|
+
sourceChannel,
|
|
99
|
+
conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
|
|
100
|
+
requesterExternalUserId: senderExternalUserId,
|
|
101
|
+
requesterChatId: externalChatId,
|
|
102
|
+
guardianExternalUserId: binding.guardianExternalUserId,
|
|
103
|
+
toolName: 'ingress_access_request',
|
|
104
|
+
questionText: `${senderIdentifier} is requesting access to the assistant`,
|
|
105
|
+
expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
void emitNotificationSignal({
|
|
109
|
+
sourceEventName: 'ingress.access_request',
|
|
110
|
+
sourceChannel,
|
|
111
|
+
sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
|
|
112
|
+
assistantId: canonicalAssistantId,
|
|
113
|
+
attentionHints: {
|
|
114
|
+
requiresAction: true,
|
|
115
|
+
urgency: 'high',
|
|
116
|
+
isAsyncBackground: false,
|
|
117
|
+
visibleInSourceNow: false,
|
|
118
|
+
},
|
|
119
|
+
contextPayload: {
|
|
120
|
+
requestId,
|
|
121
|
+
sourceChannel,
|
|
122
|
+
externalChatId,
|
|
123
|
+
senderExternalUserId,
|
|
124
|
+
senderName: senderName ?? null,
|
|
125
|
+
senderUsername: senderUsername ?? null,
|
|
126
|
+
senderIdentifier,
|
|
127
|
+
},
|
|
128
|
+
dedupeKey: `access-request:${canonicalRequest.id}`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
log.info(
|
|
132
|
+
{ sourceChannel, senderExternalUserId, senderIdentifier },
|
|
133
|
+
'Guardian notified of access request',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { notified: true, created: true, requestId: canonicalRequest.id };
|
|
137
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified inbound actor trust resolver.
|
|
3
|
+
*
|
|
4
|
+
* Produces a single trust-resolved actor context from raw inbound identity
|
|
5
|
+
* fields. Normalizes sender identity via channel-agnostic canonicalization,
|
|
6
|
+
* then resolves trust classification by checking guardian bindings and
|
|
7
|
+
* ingress member records.
|
|
8
|
+
*
|
|
9
|
+
* Trust classifications:
|
|
10
|
+
* - `guardian`: sender matches the active guardian binding for this channel.
|
|
11
|
+
* - `trusted_contact`: sender is an active ingress member (not the guardian).
|
|
12
|
+
* - `unknown`: sender has no member record or no identity could be established.
|
|
13
|
+
*
|
|
14
|
+
* The legacy `ActorRole` enum (`guardian` / `non-guardian` / `unverified_channel`)
|
|
15
|
+
* is still required by existing policy gates. The `toLegacyActorRole()` mapper
|
|
16
|
+
* converts the new trust classification to the legacy enum.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ChannelId } from '../channels/types.js';
|
|
20
|
+
import type { IngressMember } from '../memory/ingress-member-store.js';
|
|
21
|
+
import { findMember } from '../memory/ingress-member-store.js';
|
|
22
|
+
import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
|
|
23
|
+
import { normalizeAssistantId } from '../util/platform.js';
|
|
24
|
+
import { getGuardianBinding } from './channel-guardian-service.js';
|
|
25
|
+
import type { ActorRole, DenialReason, GuardianContext } from './guardian-context-resolver.js';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export type TrustClass = 'guardian' | 'trusted_contact' | 'unknown';
|
|
32
|
+
|
|
33
|
+
export interface ActorTrustContext {
|
|
34
|
+
/** Canonical (normalized) sender identity. Null when identity could not be established. */
|
|
35
|
+
canonicalSenderId: string | null;
|
|
36
|
+
/** Guardian binding match, if any, for this (assistantId, channel). */
|
|
37
|
+
guardianBindingMatch: {
|
|
38
|
+
guardianExternalUserId: string;
|
|
39
|
+
guardianDeliveryChatId: string | null;
|
|
40
|
+
} | null;
|
|
41
|
+
/** Ingress member record, if any, for this sender. */
|
|
42
|
+
memberRecord: IngressMember | null;
|
|
43
|
+
/** Trust classification. */
|
|
44
|
+
trustClass: TrustClass;
|
|
45
|
+
/** Assistant-facing metadata for downstream consumption. */
|
|
46
|
+
actorMetadata: {
|
|
47
|
+
identifier: string | undefined;
|
|
48
|
+
displayName: string | undefined;
|
|
49
|
+
username: string | undefined;
|
|
50
|
+
channel: ChannelId;
|
|
51
|
+
trustStatus: TrustClass;
|
|
52
|
+
};
|
|
53
|
+
/** Legacy denial reason for backward-compatible unverified_channel paths. */
|
|
54
|
+
denialReason?: DenialReason;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResolveActorTrustInput {
|
|
58
|
+
assistantId: string;
|
|
59
|
+
sourceChannel: ChannelId;
|
|
60
|
+
externalChatId: string;
|
|
61
|
+
senderExternalUserId?: string;
|
|
62
|
+
senderUsername?: string;
|
|
63
|
+
senderDisplayName?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Resolver
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the inbound actor's trust context from raw identity fields.
|
|
72
|
+
*
|
|
73
|
+
* 1. Canonicalize the sender identity (E.164 for phone channels, trimmed ID otherwise).
|
|
74
|
+
* 2. Look up the guardian binding for (assistantId, channel).
|
|
75
|
+
* 3. Compare canonical sender identity to the guardian binding.
|
|
76
|
+
* 4. Look up the ingress member record using the canonical identity.
|
|
77
|
+
* 5. Classify: guardian > trusted_contact (active member) > unknown.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
|
|
80
|
+
const assistantId = normalizeAssistantId(input.assistantId);
|
|
81
|
+
|
|
82
|
+
const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
|
|
83
|
+
? input.senderExternalUserId.trim()
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
|
|
87
|
+
? input.senderUsername.trim()
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
const senderDisplayName = typeof input.senderDisplayName === 'string' && input.senderDisplayName.trim().length > 0
|
|
91
|
+
? input.senderDisplayName.trim()
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
// Canonical identity: normalize phone-like channels to E.164.
|
|
95
|
+
const canonicalSenderId = rawUserId
|
|
96
|
+
? canonicalizeInboundIdentity(input.sourceChannel, rawUserId)
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
const identifier = senderUsername ? `@${senderUsername}` : canonicalSenderId ?? undefined;
|
|
100
|
+
|
|
101
|
+
// No identity at all => unknown
|
|
102
|
+
if (!canonicalSenderId) {
|
|
103
|
+
return {
|
|
104
|
+
canonicalSenderId: null,
|
|
105
|
+
guardianBindingMatch: null,
|
|
106
|
+
memberRecord: null,
|
|
107
|
+
trustClass: 'unknown',
|
|
108
|
+
actorMetadata: {
|
|
109
|
+
identifier,
|
|
110
|
+
displayName: senderDisplayName,
|
|
111
|
+
username: senderUsername,
|
|
112
|
+
channel: input.sourceChannel,
|
|
113
|
+
trustStatus: 'unknown',
|
|
114
|
+
},
|
|
115
|
+
denialReason: 'no_identity',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Guardian binding lookup
|
|
120
|
+
const binding = getGuardianBinding(assistantId, input.sourceChannel);
|
|
121
|
+
const guardianBindingMatch = binding
|
|
122
|
+
? { guardianExternalUserId: binding.guardianExternalUserId, guardianDeliveryChatId: binding.guardianDeliveryChatId }
|
|
123
|
+
: null;
|
|
124
|
+
|
|
125
|
+
// Check if sender IS the guardian. Compare canonical sender against the
|
|
126
|
+
// binding's guardian identity (also canonicalize for phone channels to
|
|
127
|
+
// handle formatting variance in the stored binding).
|
|
128
|
+
let isGuardian = false;
|
|
129
|
+
if (binding) {
|
|
130
|
+
const canonicalGuardianId = canonicalizeInboundIdentity(input.sourceChannel, binding.guardianExternalUserId);
|
|
131
|
+
isGuardian = canonicalGuardianId === canonicalSenderId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Ingress member lookup using canonical identity.
|
|
135
|
+
const memberRecord = findMember({
|
|
136
|
+
assistantId,
|
|
137
|
+
sourceChannel: input.sourceChannel,
|
|
138
|
+
externalUserId: canonicalSenderId,
|
|
139
|
+
externalChatId: input.externalChatId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Trust classification
|
|
143
|
+
let trustClass: TrustClass;
|
|
144
|
+
if (isGuardian) {
|
|
145
|
+
trustClass = 'guardian';
|
|
146
|
+
} else if (memberRecord && memberRecord.status === 'active') {
|
|
147
|
+
trustClass = 'trusted_contact';
|
|
148
|
+
} else {
|
|
149
|
+
trustClass = 'unknown';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Denial reason for legacy compatibility
|
|
153
|
+
let denialReason: DenialReason | undefined;
|
|
154
|
+
if (!isGuardian && !binding) {
|
|
155
|
+
denialReason = 'no_binding';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
canonicalSenderId,
|
|
160
|
+
guardianBindingMatch,
|
|
161
|
+
memberRecord,
|
|
162
|
+
trustClass,
|
|
163
|
+
actorMetadata: {
|
|
164
|
+
identifier,
|
|
165
|
+
displayName: senderDisplayName,
|
|
166
|
+
username: senderUsername,
|
|
167
|
+
channel: input.sourceChannel,
|
|
168
|
+
trustStatus: trustClass,
|
|
169
|
+
},
|
|
170
|
+
denialReason,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Legacy compatibility mapper
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Map the new trust classification to the legacy ActorRole enum used by
|
|
180
|
+
* existing policy gates. This preserves backward compatibility while the
|
|
181
|
+
* codebase migrates to the unified trust model.
|
|
182
|
+
*
|
|
183
|
+
* Mapping:
|
|
184
|
+
* - guardian => 'guardian'
|
|
185
|
+
* - trusted_contact => 'non-guardian' (existing gates treat active members as non-guardian)
|
|
186
|
+
* - unknown (no_identity) => 'unverified_channel'
|
|
187
|
+
* - unknown (no_binding) => 'unverified_channel'
|
|
188
|
+
* - unknown (with binding, not guardian) => 'non-guardian'
|
|
189
|
+
*/
|
|
190
|
+
export function toLegacyActorRole(ctx: ActorTrustContext): ActorRole {
|
|
191
|
+
if (ctx.trustClass === 'guardian') return 'guardian';
|
|
192
|
+
if (ctx.trustClass === 'trusted_contact') return 'non-guardian';
|
|
193
|
+
|
|
194
|
+
// unknown: distinguish between unverified_channel and non-guardian
|
|
195
|
+
if (ctx.denialReason === 'no_identity' || ctx.denialReason === 'no_binding') {
|
|
196
|
+
return 'unverified_channel';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Has a binding, has identity, but not guardian and not a member => non-guardian
|
|
200
|
+
if (ctx.guardianBindingMatch && ctx.canonicalSenderId) {
|
|
201
|
+
return 'non-guardian';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return 'unverified_channel';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Convert an ActorTrustContext into the legacy GuardianContext shape that
|
|
209
|
+
* existing route-level code expects. This is a bridge for incremental
|
|
210
|
+
* migration — new code should consume ActorTrustContext directly.
|
|
211
|
+
*/
|
|
212
|
+
export function toGuardianContextCompat(ctx: ActorTrustContext, externalChatId: string): GuardianContext {
|
|
213
|
+
const actorRole = toLegacyActorRole(ctx);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
actorRole,
|
|
217
|
+
guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
|
|
218
|
+
(actorRole === 'guardian' ? externalChatId : undefined),
|
|
219
|
+
guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
|
|
220
|
+
requesterIdentifier: ctx.actorMetadata.identifier,
|
|
221
|
+
requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
|
|
222
|
+
requesterChatId: externalChatId,
|
|
223
|
+
denialReason: ctx.denialReason,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -231,11 +231,19 @@ export function validateAndConsumeChallenge(
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// For Telegram
|
|
235
|
-
//
|
|
234
|
+
// For chat-based channels (Telegram, Slack, etc.): when both
|
|
235
|
+
// expectedExternalUserId and expectedChatId are set, require the
|
|
236
|
+
// externalUserId match — chatId alone is insufficient because chat IDs
|
|
237
|
+
// can be shared (e.g. Slack channel IDs, Telegram group chat IDs) and
|
|
238
|
+
// would let any participant in the same chat satisfy identity binding.
|
|
239
|
+
// Fall back to chatId-only match only when expectedExternalUserId is
|
|
240
|
+
// not available (legacy sessions or channels without user-level identity).
|
|
236
241
|
if (challenge.expectedChatId != null) {
|
|
237
|
-
if (
|
|
238
|
-
|
|
242
|
+
if (challenge.expectedExternalUserId != null) {
|
|
243
|
+
if (actorExternalUserId === challenge.expectedExternalUserId) {
|
|
244
|
+
identityMatch = true;
|
|
245
|
+
}
|
|
246
|
+
} else if (actorChatId === challenge.expectedChatId) {
|
|
239
247
|
identityMatch = true;
|
|
240
248
|
}
|
|
241
249
|
}
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* This module centralizes how we classify an inbound actor as
|
|
5
5
|
* guardian/non-guardian/unverified so every channel path uses the same
|
|
6
6
|
* source-of-truth logic.
|
|
7
|
+
*
|
|
8
|
+
* Guardian binding comparisons now use canonicalized identities (E.164 for
|
|
9
|
+
* phone-like channels) to eliminate formatting-variance mismatches.
|
|
7
10
|
*/
|
|
8
11
|
import type { ChannelId } from '../channels/types.js';
|
|
9
12
|
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
13
|
+
import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
|
|
10
14
|
import { normalizeAssistantId } from '../util/platform.js';
|
|
11
15
|
import { getGuardianBinding } from './channel-guardian-service.js';
|
|
12
16
|
|
|
@@ -41,18 +45,32 @@ export interface ResolveGuardianContextInput {
|
|
|
41
45
|
* - active binding exists but sender differs -> non-guardian
|
|
42
46
|
* - no sender identity -> unverified_channel (no_identity)
|
|
43
47
|
* - no binding -> unverified_channel (no_binding)
|
|
48
|
+
*
|
|
49
|
+
* Identity comparison is normalization-safe: both the sender ID and the
|
|
50
|
+
* guardian binding ID are canonicalized for the source channel before
|
|
51
|
+
* comparison, so formatting differences (e.g. `+1 (555) 123-4567` vs
|
|
52
|
+
* `+15551234567`) do not cause false non-guardian classifications.
|
|
44
53
|
*/
|
|
45
54
|
export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
|
|
46
55
|
const assistantId = normalizeAssistantId(input.assistantId);
|
|
47
|
-
const
|
|
56
|
+
const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
|
|
48
57
|
? input.senderExternalUserId.trim()
|
|
49
58
|
: undefined;
|
|
50
59
|
const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
|
|
51
60
|
? input.senderUsername.trim()
|
|
52
61
|
: undefined;
|
|
53
|
-
const requesterIdentifier = senderUsername ? `@${senderUsername}` : senderExternalUserId;
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
// Canonicalize sender identity for normalization-safe comparisons.
|
|
64
|
+
// canonicalizeInboundIdentity returns string | null; coerce to
|
|
65
|
+
// string | undefined so assignments to optional (string | undefined)
|
|
66
|
+
// fields in GuardianContext don't produce a type mismatch.
|
|
67
|
+
const canonicalSenderId = rawUserId
|
|
68
|
+
? (canonicalizeInboundIdentity(input.sourceChannel, rawUserId) ?? undefined)
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
const requesterIdentifier = senderUsername ? `@${senderUsername}` : canonicalSenderId;
|
|
72
|
+
|
|
73
|
+
if (!canonicalSenderId) {
|
|
56
74
|
return {
|
|
57
75
|
actorRole: 'unverified_channel',
|
|
58
76
|
denialReason: 'no_identity',
|
|
@@ -68,18 +86,25 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
|
|
|
68
86
|
actorRole: 'unverified_channel',
|
|
69
87
|
denialReason: 'no_binding',
|
|
70
88
|
requesterIdentifier,
|
|
71
|
-
requesterExternalUserId:
|
|
89
|
+
requesterExternalUserId: canonicalSenderId,
|
|
72
90
|
requesterChatId: input.externalChatId,
|
|
73
91
|
};
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
|
|
94
|
+
// Canonicalize the stored guardian identity for the same channel so
|
|
95
|
+
// phone-format variance in the binding record doesn't cause mismatches.
|
|
96
|
+
const canonicalGuardianId = canonicalizeInboundIdentity(
|
|
97
|
+
input.sourceChannel,
|
|
98
|
+
binding.guardianExternalUserId,
|
|
99
|
+
) ?? undefined;
|
|
100
|
+
|
|
101
|
+
if (canonicalGuardianId === canonicalSenderId) {
|
|
77
102
|
return {
|
|
78
103
|
actorRole: 'guardian',
|
|
79
104
|
guardianChatId: binding.guardianDeliveryChatId || input.externalChatId,
|
|
80
105
|
guardianExternalUserId: binding.guardianExternalUserId,
|
|
81
106
|
requesterIdentifier,
|
|
82
|
-
requesterExternalUserId:
|
|
107
|
+
requesterExternalUserId: canonicalSenderId,
|
|
83
108
|
requesterChatId: input.externalChatId,
|
|
84
109
|
};
|
|
85
110
|
}
|
|
@@ -89,7 +114,7 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
|
|
|
89
114
|
guardianChatId: binding.guardianDeliveryChatId,
|
|
90
115
|
guardianExternalUserId: binding.guardianExternalUserId,
|
|
91
116
|
requesterIdentifier,
|
|
92
|
-
requesterExternalUserId:
|
|
117
|
+
requesterExternalUserId: canonicalSenderId,
|
|
93
118
|
requesterChatId: input.externalChatId,
|
|
94
119
|
};
|
|
95
120
|
}
|
|
@@ -22,6 +22,12 @@ export interface GuardianDecisionPrompt {
|
|
|
22
22
|
expiresAt: number;
|
|
23
23
|
conversationId: string;
|
|
24
24
|
callSessionId: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Canonical request kind (e.g. 'tool_approval', 'pending_question').
|
|
27
|
+
* Present when the prompt originates from the canonical guardian request
|
|
28
|
+
* store. Absent for legacy-only prompts.
|
|
29
|
+
*/
|
|
30
|
+
kind?: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface GuardianDecisionAction {
|