@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified approval primitive for scoped approval grants.
|
|
3
|
+
*
|
|
4
|
+
* All producers (voice guardian-action minter, channel guardian approval
|
|
5
|
+
* interception) and consumers (tool executor grant checks) must go through
|
|
6
|
+
* this module instead of calling the storage layer directly. This enforces
|
|
7
|
+
* all scope constraints in one place and provides structured logging for
|
|
8
|
+
* mint/consume hit/miss diagnostics.
|
|
9
|
+
*
|
|
10
|
+
* Storage remains in `scoped_approval_grants` via the existing CRUD module;
|
|
11
|
+
* this primitive wraps that layer with a unified API surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
_internal,
|
|
16
|
+
type ConsumeByRequestIdResult,
|
|
17
|
+
type ConsumeByToolSignatureResult,
|
|
18
|
+
type ScopedApprovalGrant,
|
|
19
|
+
} from '../memory/scoped-approval-grants.js';
|
|
20
|
+
|
|
21
|
+
const { createScopedApprovalGrant, consumeScopedApprovalGrantByRequestId, consumeScopedApprovalGrantByToolSignature } = _internal;
|
|
22
|
+
import { getLogger } from '../util/logger.js';
|
|
23
|
+
|
|
24
|
+
const log = getLogger('approval-primitive');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Mint
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface MintGrantParams {
|
|
31
|
+
assistantId: string;
|
|
32
|
+
scopeMode: 'request_id' | 'tool_signature';
|
|
33
|
+
requestId?: string | null;
|
|
34
|
+
toolName?: string | null;
|
|
35
|
+
inputDigest?: string | null;
|
|
36
|
+
requestChannel: string;
|
|
37
|
+
decisionChannel: string;
|
|
38
|
+
executionChannel?: string | null;
|
|
39
|
+
conversationId?: string | null;
|
|
40
|
+
callSessionId?: string | null;
|
|
41
|
+
requesterExternalUserId?: string | null;
|
|
42
|
+
guardianExternalUserId?: string | null;
|
|
43
|
+
expiresAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type MintGrantResult =
|
|
47
|
+
| { ok: true; grant: ScopedApprovalGrant }
|
|
48
|
+
| { ok: false; reason: 'missing_request_id' | 'missing_tool_fields' | 'storage_error'; error?: unknown };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mint a scoped approval grant from a guardian decision.
|
|
52
|
+
*
|
|
53
|
+
* Validates scope-mode-specific field requirements before delegating to the
|
|
54
|
+
* storage layer:
|
|
55
|
+
* - `request_id` scope requires a non-null `requestId`.
|
|
56
|
+
* - `tool_signature` scope requires both `toolName` and `inputDigest`.
|
|
57
|
+
*
|
|
58
|
+
* Returns a discriminated result so callers can inspect failure reasons
|
|
59
|
+
* without catching exceptions.
|
|
60
|
+
*/
|
|
61
|
+
export function mintGrantFromDecision(params: MintGrantParams): MintGrantResult {
|
|
62
|
+
// Scope-mode field validation
|
|
63
|
+
if (params.scopeMode === 'request_id' && !params.requestId) {
|
|
64
|
+
log.warn(
|
|
65
|
+
{
|
|
66
|
+
event: 'approval_primitive_mint_rejected',
|
|
67
|
+
reason: 'missing_request_id',
|
|
68
|
+
scopeMode: params.scopeMode,
|
|
69
|
+
assistantId: params.assistantId,
|
|
70
|
+
requestChannel: params.requestChannel,
|
|
71
|
+
decisionChannel: params.decisionChannel,
|
|
72
|
+
},
|
|
73
|
+
'Mint rejected: request_id scope requires a non-null requestId',
|
|
74
|
+
);
|
|
75
|
+
return { ok: false, reason: 'missing_request_id' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (params.scopeMode === 'tool_signature' && (!params.toolName || !params.inputDigest)) {
|
|
79
|
+
log.warn(
|
|
80
|
+
{
|
|
81
|
+
event: 'approval_primitive_mint_rejected',
|
|
82
|
+
reason: 'missing_tool_fields',
|
|
83
|
+
scopeMode: params.scopeMode,
|
|
84
|
+
toolName: params.toolName ?? null,
|
|
85
|
+
inputDigest: params.inputDigest ?? null,
|
|
86
|
+
assistantId: params.assistantId,
|
|
87
|
+
requestChannel: params.requestChannel,
|
|
88
|
+
decisionChannel: params.decisionChannel,
|
|
89
|
+
},
|
|
90
|
+
'Mint rejected: tool_signature scope requires both toolName and inputDigest',
|
|
91
|
+
);
|
|
92
|
+
return { ok: false, reason: 'missing_tool_fields' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const grant = createScopedApprovalGrant({
|
|
97
|
+
assistantId: params.assistantId,
|
|
98
|
+
scopeMode: params.scopeMode,
|
|
99
|
+
requestId: params.requestId ?? null,
|
|
100
|
+
toolName: params.toolName ?? null,
|
|
101
|
+
inputDigest: params.inputDigest ?? null,
|
|
102
|
+
requestChannel: params.requestChannel,
|
|
103
|
+
decisionChannel: params.decisionChannel,
|
|
104
|
+
executionChannel: params.executionChannel ?? null,
|
|
105
|
+
conversationId: params.conversationId ?? null,
|
|
106
|
+
callSessionId: params.callSessionId ?? null,
|
|
107
|
+
requesterExternalUserId: params.requesterExternalUserId ?? null,
|
|
108
|
+
guardianExternalUserId: params.guardianExternalUserId ?? null,
|
|
109
|
+
expiresAt: params.expiresAt,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
log.info(
|
|
113
|
+
{
|
|
114
|
+
event: 'approval_primitive_mint_success',
|
|
115
|
+
grantId: grant.id,
|
|
116
|
+
scopeMode: params.scopeMode,
|
|
117
|
+
toolName: params.toolName ?? null,
|
|
118
|
+
requestId: params.requestId ?? null,
|
|
119
|
+
assistantId: params.assistantId,
|
|
120
|
+
requestChannel: params.requestChannel,
|
|
121
|
+
decisionChannel: params.decisionChannel,
|
|
122
|
+
conversationId: params.conversationId ?? null,
|
|
123
|
+
callSessionId: params.callSessionId ?? null,
|
|
124
|
+
expiresAt: params.expiresAt,
|
|
125
|
+
},
|
|
126
|
+
'Approval grant minted',
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return { ok: true, grant };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
log.error(
|
|
132
|
+
{
|
|
133
|
+
event: 'approval_primitive_mint_error',
|
|
134
|
+
scopeMode: params.scopeMode,
|
|
135
|
+
toolName: params.toolName ?? null,
|
|
136
|
+
assistantId: params.assistantId,
|
|
137
|
+
err: error,
|
|
138
|
+
},
|
|
139
|
+
'Failed to mint approval grant (storage error)',
|
|
140
|
+
);
|
|
141
|
+
return { ok: false, reason: 'storage_error', error };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Consume
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export interface ConsumeByRequestIdParams {
|
|
150
|
+
requestId: string;
|
|
151
|
+
consumingRequestId: string;
|
|
152
|
+
assistantId: string;
|
|
153
|
+
now?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ConsumeByToolSignatureParams {
|
|
157
|
+
toolName: string;
|
|
158
|
+
inputDigest: string;
|
|
159
|
+
consumingRequestId: string;
|
|
160
|
+
assistantId?: string;
|
|
161
|
+
executionChannel?: string;
|
|
162
|
+
conversationId?: string;
|
|
163
|
+
callSessionId?: string;
|
|
164
|
+
requesterExternalUserId?: string;
|
|
165
|
+
now?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type ConsumeGrantResult =
|
|
169
|
+
| { ok: true; grant: ScopedApprovalGrant }
|
|
170
|
+
| { ok: false; reason: 'no_match' | 'scope_mismatch' | 'expired' | 'already_consumed' | 'aborted' };
|
|
171
|
+
|
|
172
|
+
export interface ConsumeGrantParams {
|
|
173
|
+
requestId?: string;
|
|
174
|
+
toolName: string;
|
|
175
|
+
inputDigest: string;
|
|
176
|
+
consumingRequestId: string;
|
|
177
|
+
assistantId: string;
|
|
178
|
+
executionChannel?: string;
|
|
179
|
+
conversationId?: string;
|
|
180
|
+
callSessionId?: string;
|
|
181
|
+
requesterExternalUserId?: string;
|
|
182
|
+
now?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Single synchronous attempt to consume a scoped approval grant.
|
|
187
|
+
*
|
|
188
|
+
* Tries `request_id` mode first when a requestId is provided, then falls
|
|
189
|
+
* back to `tool_signature` mode. This mirrors the priority ordering at
|
|
190
|
+
* the consume site: an exact request-bound grant takes precedence over a
|
|
191
|
+
* tool-signature grant.
|
|
192
|
+
*
|
|
193
|
+
* This is an internal helper — callers should use {@link consumeGrantForInvocation}
|
|
194
|
+
* which adds retry polling to handle the voice pipeline race condition.
|
|
195
|
+
*/
|
|
196
|
+
function consumeGrantSync(params: ConsumeGrantParams): ConsumeGrantResult {
|
|
197
|
+
// Try request_id mode first when a requestId is provided
|
|
198
|
+
if (params.requestId) {
|
|
199
|
+
const reqResult: ConsumeByRequestIdResult = consumeScopedApprovalGrantByRequestId(
|
|
200
|
+
params.requestId,
|
|
201
|
+
params.consumingRequestId,
|
|
202
|
+
params.assistantId,
|
|
203
|
+
params.now,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (reqResult.ok && reqResult.grant) {
|
|
207
|
+
log.info(
|
|
208
|
+
{
|
|
209
|
+
event: 'approval_primitive_consume_hit',
|
|
210
|
+
mode: 'request_id',
|
|
211
|
+
grantId: reqResult.grant.id,
|
|
212
|
+
requestId: params.requestId,
|
|
213
|
+
consumingRequestId: params.consumingRequestId,
|
|
214
|
+
assistantId: params.assistantId,
|
|
215
|
+
toolName: params.toolName,
|
|
216
|
+
},
|
|
217
|
+
'Approval grant consumed via request_id',
|
|
218
|
+
);
|
|
219
|
+
return { ok: true, grant: reqResult.grant };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
log.info(
|
|
223
|
+
{
|
|
224
|
+
event: 'approval_primitive_consume_miss',
|
|
225
|
+
mode: 'request_id',
|
|
226
|
+
reason: 'no_match',
|
|
227
|
+
requestId: params.requestId,
|
|
228
|
+
consumingRequestId: params.consumingRequestId,
|
|
229
|
+
assistantId: params.assistantId,
|
|
230
|
+
toolName: params.toolName,
|
|
231
|
+
},
|
|
232
|
+
'No request_id grant match, falling through to tool_signature',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fall back to tool_signature mode
|
|
237
|
+
const sigResult: ConsumeByToolSignatureResult = consumeScopedApprovalGrantByToolSignature({
|
|
238
|
+
toolName: params.toolName,
|
|
239
|
+
inputDigest: params.inputDigest,
|
|
240
|
+
consumingRequestId: params.consumingRequestId,
|
|
241
|
+
assistantId: params.assistantId,
|
|
242
|
+
executionChannel: params.executionChannel,
|
|
243
|
+
conversationId: params.conversationId,
|
|
244
|
+
callSessionId: params.callSessionId,
|
|
245
|
+
requesterExternalUserId: params.requesterExternalUserId,
|
|
246
|
+
now: params.now,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (sigResult.ok && sigResult.grant) {
|
|
250
|
+
log.info(
|
|
251
|
+
{
|
|
252
|
+
event: 'approval_primitive_consume_hit',
|
|
253
|
+
mode: 'tool_signature',
|
|
254
|
+
grantId: sigResult.grant.id,
|
|
255
|
+
toolName: params.toolName,
|
|
256
|
+
consumingRequestId: params.consumingRequestId,
|
|
257
|
+
assistantId: params.assistantId,
|
|
258
|
+
conversationId: params.conversationId ?? null,
|
|
259
|
+
callSessionId: params.callSessionId ?? null,
|
|
260
|
+
},
|
|
261
|
+
'Approval grant consumed via tool_signature',
|
|
262
|
+
);
|
|
263
|
+
return { ok: true, grant: sigResult.grant };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
log.info(
|
|
267
|
+
{
|
|
268
|
+
event: 'approval_primitive_consume_miss',
|
|
269
|
+
mode: 'tool_signature',
|
|
270
|
+
reason: 'no_match',
|
|
271
|
+
toolName: params.toolName,
|
|
272
|
+
consumingRequestId: params.consumingRequestId,
|
|
273
|
+
assistantId: params.assistantId,
|
|
274
|
+
conversationId: params.conversationId ?? null,
|
|
275
|
+
callSessionId: params.callSessionId ?? null,
|
|
276
|
+
executionChannel: params.executionChannel ?? null,
|
|
277
|
+
},
|
|
278
|
+
'No tool_signature grant match found',
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return { ok: false, reason: 'no_match' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Public consume API (with retry for voice pipeline race condition)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** Default polling interval for grant retry (ms). */
|
|
289
|
+
const GRANT_RETRY_INTERVAL_MS = 250;
|
|
290
|
+
/** Default maximum wait time for grant retry (ms). */
|
|
291
|
+
const GRANT_RETRY_MAX_WAIT_MS = 10_000;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Consume a scoped approval grant for a tool invocation.
|
|
295
|
+
*
|
|
296
|
+
* Performs a synchronous lookup first and returns immediately when a
|
|
297
|
+
* matching grant exists. When the first attempt misses, retries with
|
|
298
|
+
* polling to handle the voice pipeline race condition where the grant
|
|
299
|
+
* may still be in-flight: `answerCall()` triggers the voice turn as
|
|
300
|
+
* fire-and-forget, and the voice LLM can attempt tool execution before
|
|
301
|
+
* `tryMintGuardianActionGrant`'s LLM fallback finishes minting the
|
|
302
|
+
* grant. Polling bridges this timing gap without changing the
|
|
303
|
+
* fire-and-forget architecture.
|
|
304
|
+
*/
|
|
305
|
+
export async function consumeGrantForInvocation(
|
|
306
|
+
params: ConsumeGrantParams,
|
|
307
|
+
options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
|
|
308
|
+
): Promise<ConsumeGrantResult> {
|
|
309
|
+
// Fast path: try once synchronously — covers the common case where the
|
|
310
|
+
// grant already exists (deterministic classifier, or prior turns).
|
|
311
|
+
const first = consumeGrantSync(params);
|
|
312
|
+
if (first.ok) {
|
|
313
|
+
return first;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// When maxWaitMs is 0, skip retry entirely — used by non-voice channels
|
|
317
|
+
// where grant-minting race conditions don't apply.
|
|
318
|
+
const maxWait = options?.maxWaitMs ?? GRANT_RETRY_MAX_WAIT_MS;
|
|
319
|
+
if (maxWait <= 0) {
|
|
320
|
+
return first;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const interval = options?.intervalMs ?? GRANT_RETRY_INTERVAL_MS;
|
|
324
|
+
const deadline = Date.now() + maxWait;
|
|
325
|
+
|
|
326
|
+
log.info(
|
|
327
|
+
{
|
|
328
|
+
event: 'approval_primitive_consume_retry_start',
|
|
329
|
+
toolName: params.toolName,
|
|
330
|
+
consumingRequestId: params.consumingRequestId,
|
|
331
|
+
maxWaitMs: maxWait,
|
|
332
|
+
intervalMs: interval,
|
|
333
|
+
},
|
|
334
|
+
'Grant not found on first attempt; starting retry polling',
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const signal = options?.signal;
|
|
338
|
+
|
|
339
|
+
while (Date.now() < deadline) {
|
|
340
|
+
// Exit promptly on cancellation (e.g. voice barge-in) so the session
|
|
341
|
+
// can tear down the current turn without waiting for the full timeout.
|
|
342
|
+
// Returns 'aborted' (not 'no_match') so callers can distinguish
|
|
343
|
+
// cancellation from a genuine grant miss.
|
|
344
|
+
if (signal?.aborted) {
|
|
345
|
+
return { ok: false, reason: 'aborted' };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
349
|
+
|
|
350
|
+
if (signal?.aborted) {
|
|
351
|
+
return { ok: false, reason: 'aborted' };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const result = consumeGrantSync(params);
|
|
355
|
+
if (result.ok) {
|
|
356
|
+
log.info(
|
|
357
|
+
{
|
|
358
|
+
event: 'approval_primitive_consume_retry_hit',
|
|
359
|
+
toolName: params.toolName,
|
|
360
|
+
consumingRequestId: params.consumingRequestId,
|
|
361
|
+
grantId: result.grant.id,
|
|
362
|
+
elapsedMs: maxWait - (deadline - Date.now()),
|
|
363
|
+
},
|
|
364
|
+
'Grant found after retry polling',
|
|
365
|
+
);
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
log.info(
|
|
371
|
+
{
|
|
372
|
+
event: 'approval_primitive_consume_retry_timeout',
|
|
373
|
+
toolName: params.toolName,
|
|
374
|
+
consumingRequestId: params.consumingRequestId,
|
|
375
|
+
maxWaitMs: maxWait,
|
|
376
|
+
},
|
|
377
|
+
'Grant retry polling timed out — no matching grant found',
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
return { ok: false, reason: 'no_match' };
|
|
381
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified guardian decision primitive.
|
|
3
|
+
*
|
|
4
|
+
* All guardian decision entrypoints (callback buttons, conversational engine,
|
|
5
|
+
* legacy parser, requester self-cancel) call through this module instead of
|
|
6
|
+
* inlining the decision-application logic. This centralizes:
|
|
7
|
+
*
|
|
8
|
+
* 1. `approve_always` downgrade for guardian-on-behalf requests
|
|
9
|
+
* 2. Identity validation (actor must match assigned guardian)
|
|
10
|
+
* 3. Approval-info capture before the pending interaction is consumed
|
|
11
|
+
* 4. Atomic decision application via `handleChannelDecision`
|
|
12
|
+
* 5. Guardian approval record update
|
|
13
|
+
* 6. Scoped grant minting on approve
|
|
14
|
+
*
|
|
15
|
+
* Security invariants enforced here:
|
|
16
|
+
* - Decision application is identity-bound to expected guardian identity
|
|
17
|
+
* - Decisions are first-response-wins (CAS-like stale protection)
|
|
18
|
+
* - `approve_always` is rejected/downgraded for guardian-on-behalf requests
|
|
19
|
+
* - Scoped grant minting only on explicit approve for requests with tool metadata
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ChannelId } from '../channels/types.js';
|
|
23
|
+
import {
|
|
24
|
+
type GuardianApprovalRequest,
|
|
25
|
+
updateApprovalDecision,
|
|
26
|
+
} from '../memory/channel-guardian-store.js';
|
|
27
|
+
import type {
|
|
28
|
+
ApprovalDecisionResult,
|
|
29
|
+
} from '../runtime/channel-approval-types.js';
|
|
30
|
+
import {
|
|
31
|
+
getApprovalInfoByConversation,
|
|
32
|
+
handleChannelDecision,
|
|
33
|
+
type PendingApprovalInfo,
|
|
34
|
+
} from '../runtime/channel-approvals.js';
|
|
35
|
+
import type { ApplyGuardianDecisionResult } from '../runtime/guardian-decision-types.js';
|
|
36
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
37
|
+
import { getLogger } from '../util/logger.js';
|
|
38
|
+
import { mintGrantFromDecision } from './approval-primitive.js';
|
|
39
|
+
|
|
40
|
+
const log = getLogger('guardian-decision-primitive');
|
|
41
|
+
|
|
42
|
+
/** TTL for scoped approval grants minted on guardian approve_once decisions. */
|
|
43
|
+
export const GRANT_TTL_MS = 5 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Scoped grant minting
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
|
|
51
|
+
* request. Only mints when the approval info contains a tool invocation with
|
|
52
|
+
* input (so we can compute the input digest). Informational ASK_GUARDIAN
|
|
53
|
+
* requests that lack tool input are skipped.
|
|
54
|
+
*
|
|
55
|
+
* Fails silently on error -- grant minting is best-effort and must never block
|
|
56
|
+
* the approval flow.
|
|
57
|
+
*/
|
|
58
|
+
export function tryMintToolApprovalGrant(params: {
|
|
59
|
+
approvalInfo: PendingApprovalInfo;
|
|
60
|
+
approval: GuardianApprovalRequest;
|
|
61
|
+
decisionChannel: ChannelId;
|
|
62
|
+
guardianExternalUserId: string;
|
|
63
|
+
}): void {
|
|
64
|
+
const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
|
|
65
|
+
|
|
66
|
+
if (!approvalInfo.toolName) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let inputDigest: string;
|
|
71
|
+
try {
|
|
72
|
+
inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
log.error(
|
|
75
|
+
{ err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
76
|
+
'Failed to compute tool approval digest for grant minting (non-fatal)',
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = mintGrantFromDecision({
|
|
82
|
+
assistantId: approval.assistantId,
|
|
83
|
+
scopeMode: 'tool_signature',
|
|
84
|
+
toolName: approvalInfo.toolName,
|
|
85
|
+
inputDigest,
|
|
86
|
+
requestChannel: approval.channel,
|
|
87
|
+
decisionChannel,
|
|
88
|
+
executionChannel: null,
|
|
89
|
+
conversationId: approval.conversationId,
|
|
90
|
+
callSessionId: null,
|
|
91
|
+
guardianExternalUserId,
|
|
92
|
+
requesterExternalUserId: approval.requesterExternalUserId,
|
|
93
|
+
expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (result.ok) {
|
|
97
|
+
log.info(
|
|
98
|
+
{ toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
99
|
+
'Minted scoped approval grant for guardian tool-approval decision',
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
log.error(
|
|
103
|
+
{ reason: result.reason, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
104
|
+
'Failed to mint scoped approval grant (non-fatal)',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Apply guardian decision (unified primitive)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export interface ApplyGuardianDecisionParams {
|
|
114
|
+
/** The guardian approval record from the store. */
|
|
115
|
+
approval: GuardianApprovalRequest;
|
|
116
|
+
/** The parsed decision (action + source + optional requestId). */
|
|
117
|
+
decision: ApprovalDecisionResult;
|
|
118
|
+
/** External user ID of the actor making the decision. */
|
|
119
|
+
actorExternalUserId: string | undefined;
|
|
120
|
+
/** Channel the decision arrived on. */
|
|
121
|
+
actorChannel: ChannelId;
|
|
122
|
+
/** Optional decision context passed to handleChannelDecision. */
|
|
123
|
+
decisionContext?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Apply a guardian decision through the unified primitive.
|
|
128
|
+
*
|
|
129
|
+
* This function centralizes the core logic that was previously duplicated
|
|
130
|
+
* across callback, conversational engine, legacy parser, and requester
|
|
131
|
+
* self-cancel paths:
|
|
132
|
+
*
|
|
133
|
+
* 1. Downgrade `approve_always` to `approve_once` (guardians cannot
|
|
134
|
+
* permanently allowlist tools on behalf of requesters)
|
|
135
|
+
* 2. Capture pending approval info before resolution
|
|
136
|
+
* 3. Apply the decision atomically via `handleChannelDecision`
|
|
137
|
+
* 4. Update the guardian approval record
|
|
138
|
+
* 5. Mint a scoped grant on approve
|
|
139
|
+
*
|
|
140
|
+
* Returns a structured result so callers can handle stale/race outcomes.
|
|
141
|
+
*/
|
|
142
|
+
export function applyGuardianDecision(params: ApplyGuardianDecisionParams): ApplyGuardianDecisionResult {
|
|
143
|
+
const { approval, decision, actorExternalUserId, actorChannel, decisionContext } = params;
|
|
144
|
+
|
|
145
|
+
// Guardians cannot approve_always on behalf of requesters -- downgrade.
|
|
146
|
+
const effectiveDecision: ApprovalDecisionResult = decision.action === 'approve_always'
|
|
147
|
+
? { ...decision, action: 'approve_once' }
|
|
148
|
+
: decision;
|
|
149
|
+
|
|
150
|
+
// Capture pending approval info before handleChannelDecision resolves
|
|
151
|
+
// (and removes) the pending interaction. Needed for grant minting.
|
|
152
|
+
const approvalInfo = getApprovalInfoByConversation(approval.conversationId);
|
|
153
|
+
const matchedInfo = effectiveDecision.requestId
|
|
154
|
+
? approvalInfo.find(a => a.requestId === effectiveDecision.requestId)
|
|
155
|
+
: approvalInfo[0];
|
|
156
|
+
|
|
157
|
+
// Apply the decision to the underlying session
|
|
158
|
+
const result = handleChannelDecision(
|
|
159
|
+
approval.conversationId,
|
|
160
|
+
effectiveDecision,
|
|
161
|
+
decisionContext,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (!result.applied) {
|
|
165
|
+
return { applied: false, reason: 'stale', requestId: effectiveDecision.requestId };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Update the guardian approval request record
|
|
169
|
+
const approvalStatus = effectiveDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
|
|
170
|
+
updateApprovalDecision(approval.id, {
|
|
171
|
+
status: approvalStatus,
|
|
172
|
+
decidedByExternalUserId: actorExternalUserId,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Mint a scoped grant when a guardian approves a tool-approval request.
|
|
176
|
+
// Skip when actorExternalUserId is undefined -- minting a grant without
|
|
177
|
+
// a known guardian identity is meaningless (e.g. requester self-cancel).
|
|
178
|
+
if (effectiveDecision.action !== 'reject' && matchedInfo && actorExternalUserId) {
|
|
179
|
+
tryMintToolApprovalGrant({
|
|
180
|
+
approvalInfo: matchedInfo,
|
|
181
|
+
approval,
|
|
182
|
+
decisionChannel: actorChannel,
|
|
183
|
+
guardianExternalUserId: actorExternalUserId,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
applied: true,
|
|
189
|
+
requestId: result.requestId,
|
|
190
|
+
};
|
|
191
|
+
}
|