@vellumai/assistant 0.4.3 → 0.4.4
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/.env.example +3 -0
- package/ARCHITECTURE.md +40 -3
- package/README.md +43 -35
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -87
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +4 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -4
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +131 -8
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +62 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +841 -39
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +10 -2
- package/src/approvals/guardian-request-resolvers.ts +128 -9
- package/src/calls/call-constants.ts +21 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-domain.ts +28 -7
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +424 -12
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
- package/src/config/calls-schema.ts +24 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +5 -55
- package/src/daemon/handlers/config-inbox.ts +9 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/sessions.ts +48 -3
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/integrations.ts +1 -99
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +14 -1
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +22 -11
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +3 -0
- package/src/daemon/session-surfaces.ts +3 -2
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +16 -0
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +39 -1
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +16 -8
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +71 -1
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +0 -3
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +65 -12
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/invite-redemption-service.ts +8 -0
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/conversation-routes.ts +140 -52
- package/src/runtime/routes/events-routes.ts +20 -5
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-message-handler.ts +143 -2
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/tool-approval-handler.ts +242 -18
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ServerMessage } from '../../daemon/ipc-contract.js';
|
|
2
|
+
import { browserManager, SCREENCAST_HEIGHT, SCREENCAST_WIDTH } from './browser-manager.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// Track active screencast sessions
|
|
7
|
-
const activeScreencasts = new Map<string, { surfaceId: string }>();
|
|
4
|
+
// Track which sessions have an active browser page (no PiP surface — the user
|
|
5
|
+
// watches the actual browser window directly).
|
|
6
|
+
const activeBrowserSessions = new Set<string>();
|
|
8
7
|
|
|
9
8
|
// Registry of sendToClient callbacks per session
|
|
10
9
|
const sessionSenders = new Map<string, (msg: ServerMessage) => void>();
|
|
@@ -32,118 +31,55 @@ function getSender(sessionId: string): ((msg: ServerMessage) => void) | undefine
|
|
|
32
31
|
|
|
33
32
|
export async function ensureScreencast(
|
|
34
33
|
sessionId: string,
|
|
35
|
-
|
|
34
|
+
_sendToClient: (msg: ServerMessage) => void,
|
|
36
35
|
): Promise<void> {
|
|
37
|
-
if (
|
|
36
|
+
if (activeBrowserSessions.has(sessionId)) return;
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
activeScreencasts.set(sessionId, { surfaceId });
|
|
38
|
+
activeBrowserSessions.add(sessionId);
|
|
41
39
|
|
|
42
40
|
try {
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Send surface show
|
|
49
|
-
sendToClient({
|
|
50
|
-
type: 'ui_surface_show',
|
|
51
|
-
sessionId,
|
|
52
|
-
surfaceId,
|
|
53
|
-
surfaceType: 'browser_view',
|
|
54
|
-
title: 'Browser',
|
|
55
|
-
data: {
|
|
56
|
-
sessionId,
|
|
57
|
-
currentUrl: currentUrl || 'about:blank',
|
|
58
|
-
status: 'idle',
|
|
59
|
-
pages: [{ id: sessionId, title: title || 'New Tab', url: currentUrl || 'about:blank', active: true }],
|
|
60
|
-
} satisfies BrowserViewSurfaceData,
|
|
61
|
-
display: 'panel',
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// Start CDP screencast
|
|
65
|
-
await browserManager.startScreencast(sessionId, (frame) => {
|
|
66
|
-
sendToClient({
|
|
67
|
-
type: 'browser_frame',
|
|
68
|
-
sessionId,
|
|
69
|
-
surfaceId,
|
|
70
|
-
frame: frame.data,
|
|
71
|
-
metadata: frame.metadata,
|
|
72
|
-
} satisfies BrowserFrame);
|
|
73
|
-
});
|
|
41
|
+
// Ensure the page exists (may trigger browser launch/connect)
|
|
42
|
+
await browserManager.getOrCreateSessionPage(sessionId);
|
|
43
|
+
|
|
44
|
+
// No PiP surface or CDP screencast — the user watches the actual
|
|
45
|
+
// browser window directly (positioned in top-right via positionWindowSidebar).
|
|
74
46
|
} catch (err) {
|
|
75
|
-
// Dismiss the surface we already showed so the client doesn't have an orphaned panel
|
|
76
|
-
sendToClient({
|
|
77
|
-
type: 'ui_surface_dismiss',
|
|
78
|
-
sessionId,
|
|
79
|
-
surfaceId,
|
|
80
|
-
});
|
|
81
47
|
// Roll back so future calls can retry
|
|
82
|
-
|
|
48
|
+
activeBrowserSessions.delete(sessionId);
|
|
83
49
|
throw err;
|
|
84
50
|
}
|
|
85
51
|
}
|
|
86
52
|
|
|
87
53
|
export function updateBrowserStatus(
|
|
88
54
|
sessionId: string,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
55
|
+
_sendToClient: (msg: ServerMessage) => void,
|
|
56
|
+
_status: 'navigating' | 'idle' | 'interacting',
|
|
57
|
+
_actionText?: string,
|
|
58
|
+
_currentUrl?: string,
|
|
93
59
|
): void {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const update: Record<string, unknown> = { status };
|
|
98
|
-
if (actionText !== undefined) update.actionText = actionText;
|
|
99
|
-
if (currentUrl !== undefined) update.currentUrl = currentUrl;
|
|
100
|
-
|
|
101
|
-
sendToClient({
|
|
102
|
-
type: 'ui_surface_update',
|
|
103
|
-
sessionId,
|
|
104
|
-
surfaceId: state.surfaceId,
|
|
105
|
-
data: update,
|
|
106
|
-
});
|
|
60
|
+
// No-op: PiP surface was removed so there is no ui_surface to update.
|
|
61
|
+
// The function signature is preserved to avoid churn at callsites.
|
|
62
|
+
if (!activeBrowserSessions.has(sessionId)) return;
|
|
107
63
|
}
|
|
108
64
|
|
|
109
65
|
export async function updatePagesList(
|
|
110
66
|
sessionId: string,
|
|
111
|
-
|
|
67
|
+
_sendToClient: (msg: ServerMessage) => void,
|
|
112
68
|
): Promise<void> {
|
|
113
|
-
|
|
114
|
-
if (!
|
|
115
|
-
|
|
116
|
-
const page = await browserManager.getOrCreateSessionPage(sessionId);
|
|
117
|
-
const currentUrl = page.url();
|
|
118
|
-
const title = await page.title();
|
|
119
|
-
|
|
120
|
-
sendToClient({
|
|
121
|
-
type: 'ui_surface_update',
|
|
122
|
-
sessionId,
|
|
123
|
-
surfaceId: state.surfaceId,
|
|
124
|
-
data: {
|
|
125
|
-
currentUrl,
|
|
126
|
-
pages: [{ id: sessionId, title: title || 'Untitled', url: currentUrl, active: true }],
|
|
127
|
-
},
|
|
128
|
-
});
|
|
69
|
+
// No-op: PiP surface was removed so there is no ui_surface to update.
|
|
70
|
+
if (!activeBrowserSessions.has(sessionId)) return;
|
|
129
71
|
}
|
|
130
72
|
|
|
131
73
|
export async function stopBrowserScreencast(
|
|
132
74
|
sessionId: string,
|
|
133
|
-
|
|
75
|
+
_sendToClient: (msg: ServerMessage) => void,
|
|
134
76
|
): Promise<void> {
|
|
135
|
-
|
|
136
|
-
if (!state) return;
|
|
77
|
+
if (!activeBrowserSessions.has(sessionId)) return;
|
|
137
78
|
|
|
79
|
+
// Safe no-op if CDP screencast was never started
|
|
138
80
|
await browserManager.stopScreencast(sessionId);
|
|
139
81
|
|
|
140
|
-
|
|
141
|
-
type: 'ui_surface_dismiss',
|
|
142
|
-
sessionId,
|
|
143
|
-
surfaceId: state.surfaceId,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
activeScreencasts.delete(sessionId);
|
|
82
|
+
activeBrowserSessions.delete(sessionId);
|
|
147
83
|
}
|
|
148
84
|
|
|
149
85
|
export async function getElementBounds(
|
|
@@ -175,44 +111,24 @@ export async function getElementBounds(
|
|
|
175
111
|
|
|
176
112
|
export function updateHighlights(
|
|
177
113
|
sessionId: string,
|
|
178
|
-
|
|
179
|
-
|
|
114
|
+
_sendToClient: (msg: ServerMessage) => void,
|
|
115
|
+
_highlights: Array<{ x: number; y: number; w: number; h: number; label: string }>,
|
|
180
116
|
): void {
|
|
181
|
-
|
|
182
|
-
if (!
|
|
183
|
-
sendToClient({
|
|
184
|
-
type: 'ui_surface_update',
|
|
185
|
-
sessionId,
|
|
186
|
-
surfaceId: state.surfaceId,
|
|
187
|
-
data: { highlights },
|
|
188
|
-
});
|
|
117
|
+
// No-op: PiP surface was removed so there is no ui_surface to update.
|
|
118
|
+
if (!activeBrowserSessions.has(sessionId)) return;
|
|
189
119
|
}
|
|
190
120
|
|
|
191
121
|
export async function stopAllScreencasts(): Promise<void> {
|
|
192
|
-
const
|
|
193
|
-
for (const [sessionId, state] of entries) {
|
|
122
|
+
for (const sessionId of activeBrowserSessions) {
|
|
194
123
|
try {
|
|
195
124
|
await browserManager.stopScreencast(sessionId);
|
|
196
125
|
} catch { /* best-effort */ }
|
|
197
|
-
const sender = sessionSenders.get(sessionId);
|
|
198
|
-
if (sender) {
|
|
199
|
-
sender({
|
|
200
|
-
type: 'ui_surface_dismiss',
|
|
201
|
-
sessionId,
|
|
202
|
-
surfaceId: state.surfaceId,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
126
|
}
|
|
206
|
-
|
|
127
|
+
activeBrowserSessions.clear();
|
|
207
128
|
}
|
|
208
129
|
|
|
209
130
|
export function isScreencastActive(sessionId: string): boolean {
|
|
210
|
-
return
|
|
131
|
+
return activeBrowserSessions.has(sessionId);
|
|
211
132
|
}
|
|
212
133
|
|
|
213
134
|
export { getSender };
|
|
214
|
-
|
|
215
|
-
export function getScreencastSurfaceId(sessionId: string): string | null {
|
|
216
|
-
const state = activeScreencasts.get(sessionId);
|
|
217
|
-
return state?.surfaceId ?? null;
|
|
218
|
-
}
|
|
@@ -207,9 +207,14 @@ export class PermissionChecker {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
if (response.decision === 'always_deny') {
|
|
210
|
-
|
|
210
|
+
// For non-scoped tools (empty scopeOptions), default to 'everywhere' since
|
|
211
|
+
// the client has no scope picker and will send undefined.
|
|
212
|
+
const effectiveDenyScope = scopeOptions.length === 0
|
|
213
|
+
? (response.selectedScope ?? 'everywhere')
|
|
214
|
+
: response.selectedScope;
|
|
215
|
+
const ruleSaved = !!(persistentDecisionsAllowed && response.selectedPattern && effectiveDenyScope);
|
|
211
216
|
if (ruleSaved) {
|
|
212
|
-
addRule(name, response.selectedPattern!,
|
|
217
|
+
addRule(name, response.selectedPattern!, effectiveDenyScope!, 'deny');
|
|
213
218
|
}
|
|
214
219
|
const denialReason = ruleSaved ? 'Permission denied by user (rule saved)' : 'Permission denied by user';
|
|
215
220
|
const denialMessage = ruleSaved
|
|
@@ -237,7 +242,6 @@ export class PermissionChecker {
|
|
|
237
242
|
persistentDecisionsAllowed
|
|
238
243
|
&& (response.decision === 'always_allow' || response.decision === 'always_allow_high_risk')
|
|
239
244
|
&& response.selectedPattern
|
|
240
|
-
&& response.selectedScope
|
|
241
245
|
) {
|
|
242
246
|
const ruleOptions: {
|
|
243
247
|
allowHighRisk?: boolean;
|
|
@@ -253,7 +257,14 @@ export class PermissionChecker {
|
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
const hasOptions = Object.keys(ruleOptions).length > 0;
|
|
256
|
-
|
|
260
|
+
// Only default to 'everywhere' for non-scoped tools (empty scopeOptions).
|
|
261
|
+
// For scoped tools, require an explicit scope to prevent silent permission widening.
|
|
262
|
+
const effectiveScope = scopeOptions.length === 0
|
|
263
|
+
? (response.selectedScope ?? 'everywhere')
|
|
264
|
+
: response.selectedScope;
|
|
265
|
+
if (effectiveScope) {
|
|
266
|
+
addRule(name, response.selectedPattern, effectiveScope, 'allow', 100, hasOptions ? ruleOptions : undefined);
|
|
267
|
+
}
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
return { allowed: true, decision, riskLevel };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
|
|
2
|
+
import { getCanonicalGuardianRequest, updateCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
2
3
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
|
|
3
4
|
import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
|
|
4
5
|
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
@@ -11,6 +12,112 @@ import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifec
|
|
|
11
12
|
|
|
12
13
|
const log = getLogger('tool-approval-handler');
|
|
13
14
|
|
|
15
|
+
/** Default polling interval for inline grant wait (ms). */
|
|
16
|
+
export const TC_GRANT_WAIT_INTERVAL_MS = 500;
|
|
17
|
+
/** Default maximum wait time for inline grant wait (ms). */
|
|
18
|
+
export const TC_GRANT_WAIT_MAX_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inline wait result for trusted-contact grant polling.
|
|
22
|
+
* - `granted`: a grant was minted and consumed within the wait window.
|
|
23
|
+
* - `denied`: the guardian explicitly rejected the request.
|
|
24
|
+
* - `timeout`: the wait budget expired without a decision.
|
|
25
|
+
* - `aborted`: the session was cancelled during the wait.
|
|
26
|
+
* - `escalation_failed`: the grant request could not be created.
|
|
27
|
+
*/
|
|
28
|
+
export type InlineGrantWaitOutcome =
|
|
29
|
+
| { outcome: 'granted'; grant: { id: string } }
|
|
30
|
+
| { outcome: 'denied'; requestId: string }
|
|
31
|
+
| { outcome: 'timeout'; requestId: string }
|
|
32
|
+
| { outcome: 'aborted' }
|
|
33
|
+
| { outcome: 'escalation_failed'; reason: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wait bounded for a guardian to approve a tool grant request and for the
|
|
37
|
+
* resulting grant to become consumable. Polls both the canonical request
|
|
38
|
+
* status (to detect early rejection) and the grant store (to detect approval
|
|
39
|
+
* and atomically consume the grant).
|
|
40
|
+
*
|
|
41
|
+
* Only called for trusted_contact actors with valid guardian bindings.
|
|
42
|
+
*/
|
|
43
|
+
export async function waitForInlineGrant(
|
|
44
|
+
escalationRequestId: string,
|
|
45
|
+
consumeParams: Parameters<typeof consumeGrantForInvocation>[0],
|
|
46
|
+
options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
|
|
47
|
+
): Promise<InlineGrantWaitOutcome> {
|
|
48
|
+
const maxWait = options?.maxWaitMs ?? TC_GRANT_WAIT_MAX_MS;
|
|
49
|
+
const interval = options?.intervalMs ?? TC_GRANT_WAIT_INTERVAL_MS;
|
|
50
|
+
const signal = options?.signal;
|
|
51
|
+
const deadline = Date.now() + maxWait;
|
|
52
|
+
|
|
53
|
+
log.info(
|
|
54
|
+
{
|
|
55
|
+
event: 'tc_inline_grant_wait_start',
|
|
56
|
+
escalationRequestId,
|
|
57
|
+
toolName: consumeParams.toolName,
|
|
58
|
+
maxWaitMs: maxWait,
|
|
59
|
+
intervalMs: interval,
|
|
60
|
+
},
|
|
61
|
+
'Starting inline wait for guardian grant decision',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
while (Date.now() < deadline) {
|
|
65
|
+
if (signal?.aborted) {
|
|
66
|
+
return { outcome: 'aborted' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
70
|
+
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
return { outcome: 'aborted' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if the canonical request was rejected — exit early without
|
|
76
|
+
// waiting for the full timeout.
|
|
77
|
+
const request = getCanonicalGuardianRequest(escalationRequestId);
|
|
78
|
+
if (request && request.status === 'denied') {
|
|
79
|
+
log.info(
|
|
80
|
+
{
|
|
81
|
+
event: 'tc_inline_grant_wait_denied',
|
|
82
|
+
escalationRequestId,
|
|
83
|
+
toolName: consumeParams.toolName,
|
|
84
|
+
elapsedMs: maxWait - (deadline - Date.now()),
|
|
85
|
+
},
|
|
86
|
+
'Guardian denied tool grant request during inline wait',
|
|
87
|
+
);
|
|
88
|
+
return { outcome: 'denied', requestId: escalationRequestId };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Try to consume the grant — if the guardian approved, the canonical
|
|
92
|
+
// decision primitive will have minted a scoped grant by now.
|
|
93
|
+
const grantResult = await consumeGrantForInvocation(consumeParams, { maxWaitMs: 0 });
|
|
94
|
+
if (grantResult.ok) {
|
|
95
|
+
log.info(
|
|
96
|
+
{
|
|
97
|
+
event: 'tc_inline_grant_wait_granted',
|
|
98
|
+
escalationRequestId,
|
|
99
|
+
toolName: consumeParams.toolName,
|
|
100
|
+
grantId: grantResult.grant.id,
|
|
101
|
+
elapsedMs: maxWait - (deadline - Date.now()),
|
|
102
|
+
},
|
|
103
|
+
'Grant found during inline wait — tool execution proceeding',
|
|
104
|
+
);
|
|
105
|
+
return { outcome: 'granted', grant: { id: grantResult.grant.id } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
log.info(
|
|
110
|
+
{
|
|
111
|
+
event: 'tc_inline_grant_wait_timeout',
|
|
112
|
+
escalationRequestId,
|
|
113
|
+
toolName: consumeParams.toolName,
|
|
114
|
+
maxWaitMs: maxWait,
|
|
115
|
+
},
|
|
116
|
+
'Inline grant wait timed out — no guardian decision within budget',
|
|
117
|
+
);
|
|
118
|
+
return { outcome: 'timeout', requestId: escalationRequestId };
|
|
119
|
+
}
|
|
120
|
+
|
|
14
121
|
function isUntrustedGuardianTrustClass(role: ToolContext['guardianTrustClass']): boolean {
|
|
15
122
|
return role === 'trusted_contact' || role === 'unknown';
|
|
16
123
|
}
|
|
@@ -40,12 +147,26 @@ export type PreExecutionGateResult =
|
|
|
40
147
|
| { allowed: true; tool: Tool; grantConsumed?: boolean }
|
|
41
148
|
| { allowed: false; result: ToolExecutionResult };
|
|
42
149
|
|
|
150
|
+
/** Configuration for the inline grant wait behavior. */
|
|
151
|
+
export interface InlineGrantWaitConfig {
|
|
152
|
+
/** Maximum time to wait for guardian approval (ms). Defaults to TC_GRANT_WAIT_MAX_MS. */
|
|
153
|
+
maxWaitMs?: number;
|
|
154
|
+
/** Polling interval during the wait (ms). Defaults to TC_GRANT_WAIT_INTERVAL_MS. */
|
|
155
|
+
intervalMs?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
43
158
|
/**
|
|
44
159
|
* Handles pre-execution approval gates: abort checks, guardian policy,
|
|
45
160
|
* allowed-tool-set gating, and task-run preflight checks.
|
|
46
161
|
* These run before the interactive permission prompt flow.
|
|
47
162
|
*/
|
|
48
163
|
export class ToolApprovalHandler {
|
|
164
|
+
private inlineGrantWaitConfig: InlineGrantWaitConfig;
|
|
165
|
+
|
|
166
|
+
constructor(config?: { inlineGrantWait?: InlineGrantWaitConfig }) {
|
|
167
|
+
this.inlineGrantWaitConfig = config?.inlineGrantWait ?? {};
|
|
168
|
+
}
|
|
169
|
+
|
|
49
170
|
/**
|
|
50
171
|
* Evaluate all pre-execution approval gates for a tool invocation.
|
|
51
172
|
* Returns the resolved Tool if all gates pass, or an early-return
|
|
@@ -267,12 +388,15 @@ export class ToolApprovalHandler {
|
|
|
267
388
|
return { allowed: false, result: { content: 'Cancelled', isError: true } };
|
|
268
389
|
}
|
|
269
390
|
|
|
270
|
-
// No matching grant or race condition — deny.
|
|
391
|
+
// No matching grant or race condition — deny or wait inline.
|
|
271
392
|
//
|
|
272
|
-
// For verified non-guardian actors with sufficient
|
|
273
|
-
// the guardian by creating a canonical
|
|
274
|
-
//
|
|
275
|
-
|
|
393
|
+
// For verified non-guardian actors (trusted_contact) with sufficient
|
|
394
|
+
// context, escalate to the guardian by creating a canonical
|
|
395
|
+
// tool_grant_request. Then wait bounded for the grant to become
|
|
396
|
+
// available — this lets the tool call succeed inline after guardian
|
|
397
|
+
// approval without the requester having to retry manually.
|
|
398
|
+
//
|
|
399
|
+
// Unverified actors remain fail-closed with no escalation or wait.
|
|
276
400
|
if (
|
|
277
401
|
context.guardianTrustClass === 'trusted_contact'
|
|
278
402
|
&& context.assistantId
|
|
@@ -292,24 +416,124 @@ export class ToolApprovalHandler {
|
|
|
292
416
|
questionText: `Trusted contact is requesting permission to use "${name}"`,
|
|
293
417
|
});
|
|
294
418
|
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
419
|
+
// Only wait inline if the escalation succeeded (created or deduped).
|
|
420
|
+
// If escalation failed (no binding, missing identity), fall through
|
|
421
|
+
// to the generic denial path.
|
|
422
|
+
if ('created' in escalation || 'deduped' in escalation) {
|
|
423
|
+
// Stamp the canonical request so the approval resolver knows an
|
|
424
|
+
// inline consumer is waiting. Without this, the resolver would
|
|
425
|
+
// send a stale "please retry" notification even though the
|
|
426
|
+
// original invocation is about to resume inline.
|
|
427
|
+
updateCanonicalGuardianRequest(escalation.requestId, {
|
|
428
|
+
followupState: 'inline_wait_active:' + Date.now(),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const waitResult = await waitForInlineGrant(
|
|
432
|
+
escalation.requestId,
|
|
433
|
+
deferredConsumeParams!,
|
|
434
|
+
{
|
|
435
|
+
maxWaitMs: this.inlineGrantWaitConfig.maxWaitMs,
|
|
436
|
+
intervalMs: this.inlineGrantWaitConfig.intervalMs,
|
|
437
|
+
signal: context.signal,
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (waitResult.outcome === 'granted') {
|
|
442
|
+
// Clear the inline-wait stamp now that the grant has been consumed.
|
|
443
|
+
updateCanonicalGuardianRequest(escalation.requestId, {
|
|
444
|
+
followupState: null,
|
|
445
|
+
});
|
|
446
|
+
log.info({
|
|
447
|
+
toolName: name,
|
|
448
|
+
sessionId: context.sessionId,
|
|
449
|
+
conversationId: context.conversationId,
|
|
450
|
+
trustClass: context.guardianTrustClass,
|
|
451
|
+
executionTarget,
|
|
452
|
+
grantId: waitResult.grant.id,
|
|
453
|
+
escalationRequestId: escalation.requestId,
|
|
454
|
+
}, 'Inline grant wait succeeded — allowing trusted contact tool invocation');
|
|
455
|
+
return { allowed: true, tool, grantConsumed: true };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (waitResult.outcome === 'aborted') {
|
|
459
|
+
// Clear the inline-wait stamp so a later guardian approval
|
|
460
|
+
// (if the request is still pending) will send the retry notification.
|
|
461
|
+
updateCanonicalGuardianRequest(escalation.requestId, {
|
|
462
|
+
followupState: null,
|
|
463
|
+
});
|
|
464
|
+
const durationMs = Date.now() - startTime;
|
|
465
|
+
emitLifecycleEvent({
|
|
466
|
+
type: 'error',
|
|
467
|
+
toolName: name,
|
|
468
|
+
executionTarget,
|
|
469
|
+
input,
|
|
470
|
+
workingDir: context.workingDir,
|
|
471
|
+
sessionId: context.sessionId,
|
|
472
|
+
conversationId: context.conversationId,
|
|
473
|
+
requestId: context.requestId,
|
|
474
|
+
riskLevel,
|
|
475
|
+
decision: 'error',
|
|
476
|
+
durationMs,
|
|
477
|
+
errorMessage: 'Cancelled',
|
|
478
|
+
isExpected: true,
|
|
479
|
+
errorCategory: 'tool_failure',
|
|
480
|
+
});
|
|
481
|
+
return { allowed: false, result: { content: 'Cancelled', isError: true } };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Clear the inline-wait stamp so a later guardian approval
|
|
485
|
+
// (if the request is still pending after timeout) will send
|
|
486
|
+
// the retry notification as expected.
|
|
487
|
+
updateCanonicalGuardianRequest(escalation.requestId, {
|
|
488
|
+
followupState: null,
|
|
489
|
+
});
|
|
490
|
+
|
|
303
491
|
const codeSuffix = escalation.requestCode
|
|
304
492
|
? ` (request code: ${escalation.requestCode})`
|
|
305
493
|
: '';
|
|
306
|
-
|
|
307
|
-
|
|
494
|
+
|
|
495
|
+
let escalationMessage: string;
|
|
496
|
+
if (waitResult.outcome === 'denied') {
|
|
497
|
+
escalationMessage = `Permission denied for "${name}": the guardian rejected the request${codeSuffix}.`;
|
|
498
|
+
} else {
|
|
499
|
+
// timeout
|
|
500
|
+
escalationMessage = `Permission denied for "${name}": guardian approval was not received in time${codeSuffix}. `
|
|
501
|
+
+ `Please retry after the guardian approves.`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
log.warn({
|
|
505
|
+
toolName: name,
|
|
506
|
+
sessionId: context.sessionId,
|
|
507
|
+
conversationId: context.conversationId,
|
|
508
|
+
trustClass: context.guardianTrustClass,
|
|
509
|
+
executionTarget,
|
|
510
|
+
reason: 'guardian_approval_required',
|
|
511
|
+
grantMissReason: grantResult.reason,
|
|
512
|
+
waitOutcome: waitResult.outcome,
|
|
513
|
+
escalationRequestId: escalation.requestId,
|
|
514
|
+
}, 'Inline grant wait ended without approval — denying trusted contact tool invocation');
|
|
515
|
+
const durationMs = Date.now() - startTime;
|
|
516
|
+
emitLifecycleEvent({
|
|
517
|
+
type: 'permission_denied',
|
|
518
|
+
toolName: name,
|
|
519
|
+
executionTarget,
|
|
520
|
+
input,
|
|
521
|
+
workingDir: context.workingDir,
|
|
522
|
+
sessionId: context.sessionId,
|
|
523
|
+
conversationId: context.conversationId,
|
|
524
|
+
requestId: context.requestId,
|
|
525
|
+
riskLevel,
|
|
526
|
+
decision: 'deny',
|
|
527
|
+
reason: escalationMessage,
|
|
528
|
+
durationMs,
|
|
529
|
+
});
|
|
530
|
+
return { allowed: false, result: { content: escalationMessage, isError: true } };
|
|
308
531
|
}
|
|
309
|
-
//
|
|
532
|
+
// escalation.failed — fall through to generic denial.
|
|
310
533
|
}
|
|
311
534
|
|
|
312
|
-
|
|
535
|
+
// Unknown/unverified actors or escalation failures — generic denial.
|
|
536
|
+
const reason = guardianApprovalDeniedMessage(context.guardianTrustClass, name);
|
|
313
537
|
log.warn({
|
|
314
538
|
toolName: name,
|
|
315
539
|
sessionId: context.sessionId,
|
|
@@ -318,7 +542,7 @@ export class ToolApprovalHandler {
|
|
|
318
542
|
executionTarget,
|
|
319
543
|
reason: 'guardian_approval_required',
|
|
320
544
|
grantMissReason: grantResult.reason,
|
|
321
|
-
escalated:
|
|
545
|
+
escalated: false,
|
|
322
546
|
}, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
|
|
323
547
|
const durationMs = Date.now() - startTime;
|
|
324
548
|
emitLifecycleEvent({
|