@vellumai/assistant 0.3.27 → 0.4.0
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 +81 -4
- package/Dockerfile +2 -2
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +9 -5
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +21 -19
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +212 -36
- package/src/__tests__/notification-decision-fallback.test.ts +63 -3
- package/src/__tests__/notification-decision-strategy.test.ts +78 -0
- package/src/__tests__/notification-guardian-path.test.ts +15 -15
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +126 -59
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- 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/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +358 -24
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- 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 +149 -21
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +22 -16
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +33 -6
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +68 -326
- package/src/daemon/session-runtime-assembly.ts +119 -25
- package/src/daemon/session-tool-setup.ts +3 -2
- package/src/daemon/session.ts +4 -3
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +586 -0
- package/src/memory/channel-guardian-store.ts +2 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +20 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -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 +5 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +56 -0
- package/src/notifications/copy-composer.ts +31 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +173 -0
- package/src/runtime/actor-trust-resolver.ts +221 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -71
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +717 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +20 -2
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +205 -529
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +53 -10
- package/src/tools/types.ts +13 -2
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
package/src/agent/loop.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { truncateOversizedToolResults } from '../context/tool-result-truncation.
|
|
|
4
4
|
import { getHookManager } from '../hooks/manager.js';
|
|
5
5
|
import type { ContentBlock,Message, Provider, ToolDefinition } from '../providers/types.js';
|
|
6
6
|
import type { ToolResultContent } from '../providers/types.js';
|
|
7
|
+
import type { SensitiveOutputBinding } from '../tools/sensitive-output-placeholders.js';
|
|
8
|
+
import { applyStreamingSubstitution, applySubstitutions } from '../tools/sensitive-output-placeholders.js';
|
|
7
9
|
import { getLogger, isDebug, truncateForLog } from '../util/logger.js';
|
|
8
10
|
|
|
9
11
|
const log = getLogger('agent-loop');
|
|
@@ -63,14 +65,14 @@ export class AgentLoop {
|
|
|
63
65
|
private tools: ToolDefinition[];
|
|
64
66
|
private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
|
|
65
67
|
private resolveSystemPrompt: ((history: Message[]) => ResolvedSystemPrompt) | null;
|
|
66
|
-
private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[] }>) | null;
|
|
68
|
+
private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>) | null;
|
|
67
69
|
|
|
68
70
|
constructor(
|
|
69
71
|
provider: Provider,
|
|
70
72
|
systemPrompt: string,
|
|
71
73
|
config?: Partial<AgentLoopConfig>,
|
|
72
74
|
tools?: ToolDefinition[],
|
|
73
|
-
toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[] }>,
|
|
75
|
+
toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>,
|
|
74
76
|
resolveTools?: (history: Message[]) => ToolDefinition[],
|
|
75
77
|
resolveSystemPrompt?: (history: Message[]) => ResolvedSystemPrompt,
|
|
76
78
|
) {
|
|
@@ -97,6 +99,12 @@ export class AgentLoop {
|
|
|
97
99
|
const debug = isDebug();
|
|
98
100
|
const rlog = requestId ? log.child({ requestId }) : log;
|
|
99
101
|
|
|
102
|
+
// Per-run substitution map for sensitive output placeholders.
|
|
103
|
+
// Bindings are accumulated from tool results; placeholders are
|
|
104
|
+
// resolved in streamed deltas and final assistant message text.
|
|
105
|
+
const substitutionMap = new Map<string, string>();
|
|
106
|
+
let streamingPending = '';
|
|
107
|
+
|
|
100
108
|
while (true) {
|
|
101
109
|
if (signal?.aborted) break;
|
|
102
110
|
|
|
@@ -188,7 +196,17 @@ export class AgentLoop {
|
|
|
188
196
|
config: providerConfig,
|
|
189
197
|
onEvent: (event) => {
|
|
190
198
|
if (event.type === 'text_delta') {
|
|
191
|
-
|
|
199
|
+
// Apply sensitive-output placeholder substitution (chunk-safe)
|
|
200
|
+
if (substitutionMap.size > 0) {
|
|
201
|
+
const combined = streamingPending + event.text;
|
|
202
|
+
const { emit, pending } = applyStreamingSubstitution(combined, substitutionMap);
|
|
203
|
+
streamingPending = pending;
|
|
204
|
+
if (emit.length > 0) {
|
|
205
|
+
onEvent({ type: 'text_delta', text: emit });
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
onEvent({ type: 'text_delta', text: event.text });
|
|
209
|
+
}
|
|
192
210
|
} else if (event.type === 'thinking_delta') {
|
|
193
211
|
onEvent({ type: 'thinking_delta', thinking: event.thinking });
|
|
194
212
|
} else if (event.type === 'input_json_delta') {
|
|
@@ -238,6 +256,20 @@ export class AgentLoop {
|
|
|
238
256
|
durationMs: providerDurationMs,
|
|
239
257
|
});
|
|
240
258
|
|
|
259
|
+
// Flush any buffered streaming text from the substitution pipeline
|
|
260
|
+
if (streamingPending.length > 0) {
|
|
261
|
+
const flushed = applySubstitutions(streamingPending, substitutionMap);
|
|
262
|
+
if (flushed.length > 0) {
|
|
263
|
+
onEvent({ type: 'text_delta', text: flushed });
|
|
264
|
+
}
|
|
265
|
+
streamingPending = '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build the assistant message with placeholder-only text.
|
|
269
|
+
// Both provider history and persisted conversation store must retain
|
|
270
|
+
// placeholders so the model never sees real sensitive values — neither
|
|
271
|
+
// on subsequent loop turns nor on session reload from the database.
|
|
272
|
+
// Substitution to real values happens only in streamed text_delta events.
|
|
241
273
|
const assistantMessage: Message = {
|
|
242
274
|
role: 'assistant',
|
|
243
275
|
content: response.content,
|
|
@@ -391,6 +423,17 @@ export class AgentLoop {
|
|
|
391
423
|
toolResults = await toolExecutionPromise;
|
|
392
424
|
}
|
|
393
425
|
|
|
426
|
+
// Merge sensitive output bindings from tool results into the
|
|
427
|
+
// per-run substitution map. Bindings carry placeholder->value pairs
|
|
428
|
+
// that are resolved in streamed text deltas and final message text.
|
|
429
|
+
for (const { result } of toolResults) {
|
|
430
|
+
if (result.sensitiveBindings) {
|
|
431
|
+
for (const binding of result.sensitiveBindings) {
|
|
432
|
+
substitutionMap.set(binding.placeholder, binding.value);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
394
437
|
// Collect result blocks preserving tool_use order (Promise.all maintains order)
|
|
395
438
|
const rawResultBlocks: ContentBlock[] = toolResults.map(({ toolUse, result }) => ({
|
|
396
439
|
type: 'tool_result' as const,
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* 5. Guardian approval record update
|
|
13
13
|
* 6. Scoped grant minting on approve
|
|
14
14
|
*
|
|
15
|
+
* The canonical path (`applyCanonicalGuardianDecision`) adds:
|
|
16
|
+
* 7. Canonical request lookup and status validation
|
|
17
|
+
* 8. CAS resolution via `resolveCanonicalGuardianRequest`
|
|
18
|
+
* 9. Kind-specific resolver dispatch via the resolver registry
|
|
19
|
+
*
|
|
15
20
|
* Security invariants enforced here:
|
|
16
21
|
* - Decision application is identity-bound to expected guardian identity
|
|
17
22
|
* - Decisions are first-response-wins (CAS-like stale protection)
|
|
@@ -20,11 +25,18 @@
|
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import type { ChannelId } from '../channels/types.js';
|
|
28
|
+
import {
|
|
29
|
+
type CanonicalGuardianRequest,
|
|
30
|
+
type CanonicalRequestStatus,
|
|
31
|
+
getCanonicalGuardianRequest,
|
|
32
|
+
resolveCanonicalGuardianRequest,
|
|
33
|
+
} from '../memory/canonical-guardian-store.js';
|
|
23
34
|
import {
|
|
24
35
|
type GuardianApprovalRequest,
|
|
25
36
|
updateApprovalDecision,
|
|
26
37
|
} from '../memory/channel-guardian-store.js';
|
|
27
38
|
import type {
|
|
39
|
+
ApprovalAction,
|
|
28
40
|
ApprovalDecisionResult,
|
|
29
41
|
} from '../runtime/channel-approval-types.js';
|
|
30
42
|
import {
|
|
@@ -36,6 +48,11 @@ import type { ApplyGuardianDecisionResult } from '../runtime/guardian-decision-t
|
|
|
36
48
|
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
37
49
|
import { getLogger } from '../util/logger.js';
|
|
38
50
|
import { mintGrantFromDecision } from './approval-primitive.js';
|
|
51
|
+
import {
|
|
52
|
+
type ActorContext,
|
|
53
|
+
type ChannelDeliveryContext,
|
|
54
|
+
getResolver,
|
|
55
|
+
} from './guardian-request-resolvers.js';
|
|
39
56
|
|
|
40
57
|
const log = getLogger('guardian-decision-primitive');
|
|
41
58
|
|
|
@@ -189,3 +206,271 @@ export function applyGuardianDecision(params: ApplyGuardianDecisionParams): Appl
|
|
|
189
206
|
requestId: result.requestId,
|
|
190
207
|
};
|
|
191
208
|
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Consolidated canonical grant minting
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Mint a scoped approval grant from a canonical guardian request.
|
|
216
|
+
*
|
|
217
|
+
* Works for all request kinds that carry tool metadata (toolName + inputDigest).
|
|
218
|
+
* Requests without tool metadata are silently skipped — grant minting only
|
|
219
|
+
* applies to tool-approval flows.
|
|
220
|
+
*
|
|
221
|
+
* Fails silently on error — grant minting is best-effort and must never
|
|
222
|
+
* block the approval flow.
|
|
223
|
+
*/
|
|
224
|
+
export function mintCanonicalRequestGrant(params: {
|
|
225
|
+
request: CanonicalGuardianRequest;
|
|
226
|
+
actorChannel: string;
|
|
227
|
+
guardianExternalUserId?: string;
|
|
228
|
+
}): { minted: boolean } {
|
|
229
|
+
const { request, actorChannel, guardianExternalUserId } = params;
|
|
230
|
+
|
|
231
|
+
if (!request.toolName || !request.inputDigest) {
|
|
232
|
+
return { minted: false };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const result = mintGrantFromDecision({
|
|
236
|
+
assistantId: 'self',
|
|
237
|
+
scopeMode: 'tool_signature',
|
|
238
|
+
toolName: request.toolName,
|
|
239
|
+
inputDigest: request.inputDigest,
|
|
240
|
+
requestChannel: request.sourceChannel ?? 'unknown',
|
|
241
|
+
decisionChannel: actorChannel,
|
|
242
|
+
executionChannel: null,
|
|
243
|
+
conversationId: request.conversationId ?? null,
|
|
244
|
+
callSessionId: request.callSessionId ?? null,
|
|
245
|
+
guardianExternalUserId: guardianExternalUserId ?? null,
|
|
246
|
+
requesterExternalUserId: request.requesterExternalUserId ?? null,
|
|
247
|
+
expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (result.ok) {
|
|
251
|
+
log.info(
|
|
252
|
+
{
|
|
253
|
+
event: 'canonical_grant_minted',
|
|
254
|
+
requestId: request.id,
|
|
255
|
+
toolName: request.toolName,
|
|
256
|
+
conversationId: request.conversationId,
|
|
257
|
+
},
|
|
258
|
+
'Minted scoped approval grant for canonical guardian request',
|
|
259
|
+
);
|
|
260
|
+
return { minted: true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
log.error(
|
|
264
|
+
{
|
|
265
|
+
event: 'canonical_grant_mint_failed',
|
|
266
|
+
reason: result.reason,
|
|
267
|
+
requestId: request.id,
|
|
268
|
+
toolName: request.toolName,
|
|
269
|
+
},
|
|
270
|
+
'Failed to mint scoped approval grant for canonical request (non-fatal)',
|
|
271
|
+
);
|
|
272
|
+
return { minted: false };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Canonical guardian decision primitive
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/** Valid actions for canonical guardian decisions. */
|
|
280
|
+
const VALID_CANONICAL_ACTIONS: ReadonlySet<ApprovalAction> = new Set([
|
|
281
|
+
'approve_once',
|
|
282
|
+
'approve_always',
|
|
283
|
+
'reject',
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
export interface ApplyCanonicalGuardianDecisionParams {
|
|
287
|
+
/** The canonical request ID to resolve. */
|
|
288
|
+
requestId: string;
|
|
289
|
+
/** The decision action. */
|
|
290
|
+
action: ApprovalAction;
|
|
291
|
+
/** Actor context for the entity making the decision. */
|
|
292
|
+
actorContext: ActorContext;
|
|
293
|
+
/** Optional user-supplied text (e.g. answer text for pending questions). */
|
|
294
|
+
userText?: string;
|
|
295
|
+
/** Optional channel delivery context — present when the decision arrived via a channel message. */
|
|
296
|
+
channelDeliveryContext?: ChannelDeliveryContext;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export type CanonicalDecisionResult =
|
|
300
|
+
| { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string }
|
|
301
|
+
| { applied: false; reason: 'not_found' | 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired'; detail?: string };
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply a guardian decision through the canonical request primitive.
|
|
305
|
+
*
|
|
306
|
+
* This is the future single write path for all guardian decisions. It
|
|
307
|
+
* operates on the canonical_guardian_requests table and dispatches to
|
|
308
|
+
* kind-specific resolvers via the resolver registry.
|
|
309
|
+
*
|
|
310
|
+
* Steps:
|
|
311
|
+
* 1. Look up the canonical request by ID
|
|
312
|
+
* 2. Validate: exists, pending status, identity match, valid action
|
|
313
|
+
* 3. Downgrade approve_always to approve_once (guardian-on-behalf invariant)
|
|
314
|
+
* 4. CAS resolve the canonical request atomically
|
|
315
|
+
* 5. Dispatch to kind-specific resolver
|
|
316
|
+
* 6. Mint grant if applicable
|
|
317
|
+
*/
|
|
318
|
+
export async function applyCanonicalGuardianDecision(
|
|
319
|
+
params: ApplyCanonicalGuardianDecisionParams,
|
|
320
|
+
): Promise<CanonicalDecisionResult> {
|
|
321
|
+
const { requestId, action, actorContext, userText, channelDeliveryContext } = params;
|
|
322
|
+
|
|
323
|
+
// 1. Look up the canonical request
|
|
324
|
+
const request = getCanonicalGuardianRequest(requestId);
|
|
325
|
+
if (!request) {
|
|
326
|
+
log.warn(
|
|
327
|
+
{ event: 'canonical_decision_not_found', requestId },
|
|
328
|
+
'Canonical request not found',
|
|
329
|
+
);
|
|
330
|
+
return { applied: false, reason: 'not_found' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 2a. Validate status is pending
|
|
334
|
+
if (request.status !== 'pending') {
|
|
335
|
+
log.info(
|
|
336
|
+
{ event: 'canonical_decision_already_resolved', requestId, currentStatus: request.status },
|
|
337
|
+
'Canonical request already resolved',
|
|
338
|
+
);
|
|
339
|
+
return { applied: false, reason: 'already_resolved' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 2b. Validate action is valid
|
|
343
|
+
if (!VALID_CANONICAL_ACTIONS.has(action)) {
|
|
344
|
+
log.warn(
|
|
345
|
+
{ event: 'canonical_decision_invalid_action', requestId, action },
|
|
346
|
+
'Invalid action for canonical decision',
|
|
347
|
+
);
|
|
348
|
+
return { applied: false, reason: 'invalid_action', detail: `invalid action: ${action}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 2c. Validate identity: actor must match guardian_external_user_id
|
|
352
|
+
// unless the actor is trusted (desktop) or the request has no guardian binding.
|
|
353
|
+
if (
|
|
354
|
+
request.guardianExternalUserId &&
|
|
355
|
+
!actorContext.isTrusted &&
|
|
356
|
+
actorContext.externalUserId !== request.guardianExternalUserId
|
|
357
|
+
) {
|
|
358
|
+
log.warn(
|
|
359
|
+
{
|
|
360
|
+
event: 'canonical_decision_identity_mismatch',
|
|
361
|
+
requestId,
|
|
362
|
+
expectedGuardian: request.guardianExternalUserId,
|
|
363
|
+
actualActor: actorContext.externalUserId,
|
|
364
|
+
},
|
|
365
|
+
'Actor identity does not match expected guardian',
|
|
366
|
+
);
|
|
367
|
+
return { applied: false, reason: 'identity_mismatch' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 2d. Check expiry
|
|
371
|
+
if (request.expiresAt && new Date(request.expiresAt).getTime() < Date.now()) {
|
|
372
|
+
log.info(
|
|
373
|
+
{ event: 'canonical_decision_expired', requestId, expiresAt: request.expiresAt },
|
|
374
|
+
'Canonical request has expired',
|
|
375
|
+
);
|
|
376
|
+
return { applied: false, reason: 'expired' };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 3. Downgrade approve_always to approve_once for guardian-on-behalf requests.
|
|
380
|
+
// Guardians cannot permanently allowlist tools on behalf of requesters.
|
|
381
|
+
const effectiveAction: ApprovalAction = action === 'approve_always'
|
|
382
|
+
? 'approve_once'
|
|
383
|
+
: action;
|
|
384
|
+
|
|
385
|
+
// 4. CAS resolve: atomically transition from 'pending' to terminal status
|
|
386
|
+
const targetStatus: CanonicalRequestStatus = effectiveAction === 'reject'
|
|
387
|
+
? 'denied'
|
|
388
|
+
: 'approved';
|
|
389
|
+
|
|
390
|
+
const resolved = resolveCanonicalGuardianRequest(requestId, 'pending', {
|
|
391
|
+
status: targetStatus,
|
|
392
|
+
answerText: userText,
|
|
393
|
+
decidedByExternalUserId: actorContext.externalUserId,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (!resolved) {
|
|
397
|
+
// CAS failed — someone else resolved it first
|
|
398
|
+
log.info(
|
|
399
|
+
{ event: 'canonical_decision_cas_failed', requestId },
|
|
400
|
+
'CAS resolution failed (race condition — first writer wins)',
|
|
401
|
+
);
|
|
402
|
+
return { applied: false, reason: 'already_resolved' };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 5. Dispatch to kind-specific resolver
|
|
406
|
+
let resolverFailed = false;
|
|
407
|
+
let resolverFailureReason: string | undefined;
|
|
408
|
+
const resolver = getResolver(request.kind);
|
|
409
|
+
if (resolver) {
|
|
410
|
+
const resolverResult = await resolver.resolve({
|
|
411
|
+
request: resolved,
|
|
412
|
+
decision: { action: effectiveAction, userText },
|
|
413
|
+
actor: actorContext,
|
|
414
|
+
channelDeliveryContext,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (!resolverResult.ok) {
|
|
418
|
+
log.warn(
|
|
419
|
+
{
|
|
420
|
+
event: 'canonical_decision_resolver_failed',
|
|
421
|
+
requestId,
|
|
422
|
+
kind: request.kind,
|
|
423
|
+
reason: resolverResult.reason,
|
|
424
|
+
},
|
|
425
|
+
`Resolver for kind '${request.kind}' failed: ${resolverResult.reason}`,
|
|
426
|
+
);
|
|
427
|
+
// The canonical request is already resolved (CAS succeeded), so we don't
|
|
428
|
+
// roll back. Flag the failure and fall through to grant minting so that
|
|
429
|
+
// callers see applied: true (reflecting the committed DB state) while
|
|
430
|
+
// still being informed that the resolver had an issue.
|
|
431
|
+
resolverFailed = true;
|
|
432
|
+
resolverFailureReason = resolverResult.reason;
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
log.info(
|
|
436
|
+
{ event: 'canonical_decision_no_resolver', requestId, kind: request.kind },
|
|
437
|
+
`No resolver registered for kind '${request.kind}' — CAS resolution only`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 6. Mint grant if the decision is an approval with tool metadata.
|
|
442
|
+
// Skip when the resolver failed — minting a grant on a failed side effect
|
|
443
|
+
// would allow the tool to execute without the intended resolver action
|
|
444
|
+
// (e.g. answerCall) having succeeded.
|
|
445
|
+
let grantMinted = false;
|
|
446
|
+
if (effectiveAction !== 'reject' && !resolverFailed) {
|
|
447
|
+
const grantResult = mintCanonicalRequestGrant({
|
|
448
|
+
request: resolved,
|
|
449
|
+
actorChannel: actorContext.channel,
|
|
450
|
+
guardianExternalUserId: actorContext.externalUserId ?? resolved.guardianExternalUserId ?? undefined,
|
|
451
|
+
});
|
|
452
|
+
grantMinted = grantResult.minted;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
log.info(
|
|
456
|
+
{
|
|
457
|
+
event: 'canonical_decision_applied',
|
|
458
|
+
requestId,
|
|
459
|
+
kind: request.kind,
|
|
460
|
+
action: effectiveAction,
|
|
461
|
+
targetStatus,
|
|
462
|
+
grantMinted,
|
|
463
|
+
resolverFailed,
|
|
464
|
+
},
|
|
465
|
+
resolverFailed
|
|
466
|
+
? 'Canonical guardian decision applied (CAS committed) but resolver failed'
|
|
467
|
+
: 'Canonical guardian decision applied successfully',
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
applied: true,
|
|
472
|
+
requestId,
|
|
473
|
+
grantMinted,
|
|
474
|
+
...(resolverFailed ? { resolverFailed, resolverFailureReason } : {}),
|
|
475
|
+
};
|
|
476
|
+
}
|