@vellumai/assistant 0.3.26 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tool grant request creation and guardian notification helper.
3
+ *
4
+ * Encapsulates the "create/dedupe canonical tool_grant_request + emit notification"
5
+ * logic so non-guardian channel actors can escalate tool invocations that require
6
+ * guardian approval. Modeled after the access-request-helper pattern.
7
+ *
8
+ * Invariants preserved:
9
+ * - Unverified actors are fail-closed (caller must gate before calling).
10
+ * - Guardians cannot self-approve (grant minting uses guardian identity).
11
+ * - Notification routing goes through emitNotificationSignal().
12
+ */
13
+
14
+ import type { ChannelId } from '../channels/types.js';
15
+ import {
16
+ createCanonicalGuardianDelivery,
17
+ createCanonicalGuardianRequest,
18
+ listCanonicalGuardianRequests,
19
+ } from '../memory/canonical-guardian-store.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
21
+ import { getLogger } from '../util/logger.js';
22
+ import { getGuardianBinding } from './channel-guardian-service.js';
23
+ import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
24
+
25
+ const log = getLogger('tool-grant-request-helper');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface ToolGrantRequestParams {
32
+ assistantId: string;
33
+ sourceChannel: ChannelId;
34
+ conversationId: string;
35
+ requesterExternalUserId: string;
36
+ requesterChatId?: string;
37
+ requesterIdentifier?: string;
38
+ toolName: string;
39
+ inputDigest: string;
40
+ questionText: string;
41
+ }
42
+
43
+ export type ToolGrantRequestResult =
44
+ | { created: true; requestId: string; requestCode: string | null }
45
+ | { deduped: true; requestId: string; requestCode: string | null }
46
+ | { failed: true; reason: 'no_guardian_binding' | 'missing_identity' };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helper
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Create/dedupe a canonical tool_grant_request and emit a notification signal
54
+ * so the guardian can approve or deny the tool invocation.
55
+ *
56
+ * Returns a result indicating whether a new request was created, an existing
57
+ * one was deduped, or the escalation failed (no binding, missing identity).
58
+ */
59
+ export function createOrReuseToolGrantRequest(
60
+ params: ToolGrantRequestParams,
61
+ ): ToolGrantRequestResult {
62
+ const {
63
+ assistantId,
64
+ sourceChannel,
65
+ conversationId,
66
+ requesterExternalUserId,
67
+ requesterChatId,
68
+ requesterIdentifier,
69
+ toolName,
70
+ inputDigest,
71
+ questionText,
72
+ } = params;
73
+
74
+ if (!requesterExternalUserId) {
75
+ return { failed: true, reason: 'missing_identity' };
76
+ }
77
+
78
+ const binding = getGuardianBinding(assistantId, sourceChannel);
79
+ if (!binding) {
80
+ log.debug(
81
+ { sourceChannel, assistantId },
82
+ 'No guardian binding for tool grant request escalation',
83
+ );
84
+ return { failed: true, reason: 'no_guardian_binding' };
85
+ }
86
+
87
+ // Deduplicate: skip creation if there is already a pending canonical request
88
+ // for the same requester + conversation + tool + input digest + guardian.
89
+ // Guardian identity is included so that after a guardian rebind, old requests
90
+ // tied to the previous guardian don't block creation of a new approvable request.
91
+ const existing = listCanonicalGuardianRequests({
92
+ status: 'pending',
93
+ requesterExternalUserId,
94
+ conversationId,
95
+ kind: 'tool_grant_request',
96
+ toolName,
97
+ });
98
+ const dedupeMatch = existing.find(
99
+ (r) => r.inputDigest === inputDigest && r.guardianExternalUserId === binding.guardianExternalUserId,
100
+ );
101
+ if (dedupeMatch) {
102
+ log.debug(
103
+ {
104
+ sourceChannel,
105
+ requesterExternalUserId,
106
+ toolName,
107
+ existingId: dedupeMatch.id,
108
+ },
109
+ 'Skipping duplicate tool grant request notification',
110
+ );
111
+ return { deduped: true, requestId: dedupeMatch.id, requestCode: dedupeMatch.requestCode };
112
+ }
113
+
114
+ const senderLabel = requesterIdentifier || requesterExternalUserId;
115
+ const requestId = `tool-grant-${assistantId}-${sourceChannel}-${requesterExternalUserId}-${Date.now()}`;
116
+
117
+ const canonicalRequest = createCanonicalGuardianRequest({
118
+ id: requestId,
119
+ kind: 'tool_grant_request',
120
+ sourceType: 'channel',
121
+ sourceChannel,
122
+ conversationId,
123
+ requesterExternalUserId,
124
+ requesterChatId: requesterChatId ?? undefined,
125
+ guardianExternalUserId: binding.guardianExternalUserId,
126
+ toolName,
127
+ inputDigest,
128
+ questionText,
129
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
130
+ });
131
+
132
+ // Emit notification so guardian is alerted. Uses 'guardian.question' as
133
+ // sourceEventName so that existing request-code guidance in the notification
134
+ // pipeline is preserved.
135
+ const signalPromise = emitNotificationSignal({
136
+ sourceEventName: 'guardian.question',
137
+ sourceChannel,
138
+ sourceSessionId: conversationId,
139
+ assistantId,
140
+ attentionHints: {
141
+ requiresAction: true,
142
+ urgency: 'high',
143
+ isAsyncBackground: false,
144
+ visibleInSourceNow: false,
145
+ },
146
+ contextPayload: {
147
+ requestId: canonicalRequest.id,
148
+ requestCode: canonicalRequest.requestCode,
149
+ sourceChannel,
150
+ requesterExternalUserId,
151
+ requesterChatId: requesterChatId ?? null,
152
+ requesterIdentifier: senderLabel,
153
+ toolName,
154
+ questionText,
155
+ },
156
+ dedupeKey: `tool-grant-request:${canonicalRequest.id}`,
157
+ onThreadCreated: (info) => {
158
+ createCanonicalGuardianDelivery({
159
+ requestId: canonicalRequest.id,
160
+ destinationChannel: 'vellum',
161
+ destinationConversationId: info.conversationId,
162
+ });
163
+ },
164
+ });
165
+
166
+ // Record deliveries from the notification pipeline results (fire-and-forget).
167
+ void signalPromise.then((signalResult) => {
168
+ for (const result of signalResult.deliveryResults) {
169
+ if (result.channel === 'vellum') continue; // handled in onThreadCreated
170
+ if (result.channel !== 'telegram' && result.channel !== 'sms') continue;
171
+ createCanonicalGuardianDelivery({
172
+ requestId: canonicalRequest.id,
173
+ destinationChannel: result.channel,
174
+ destinationChatId: result.destination.length > 0 ? result.destination : undefined,
175
+ });
176
+ }
177
+ });
178
+
179
+ log.info(
180
+ {
181
+ sourceChannel,
182
+ requesterExternalUserId,
183
+ toolName,
184
+ requestId: canonicalRequest.id,
185
+ requestCode: canonicalRequest.requestCode,
186
+ },
187
+ 'Guardian notified of tool grant request',
188
+ );
189
+
190
+ return {
191
+ created: true,
192
+ requestId: canonicalRequest.id,
193
+ requestCode: canonicalRequest.requestCode,
194
+ };
195
+ }
@@ -13,6 +13,7 @@ import { resolveExecutionTarget } from './execution-target.js';
13
13
  import { executeWithTimeout,safeTimeoutMs } from './execution-timeout.js';
14
14
  import { PermissionChecker } from './permission-checker.js';
15
15
  import { SecretDetectionHandler } from './secret-detection-handler.js';
16
+ import { extractAndSanitize } from './sensitive-output-placeholders.js';
16
17
  import { applyEdit } from './shared/filesystem/edit-engine.js';
17
18
  import { sandboxPolicy } from './shared/filesystem/path-policy.js';
18
19
  import { MAX_FILE_SIZE_BYTES } from './shared/filesystem/size-guard.js';
@@ -182,6 +183,15 @@ export class ToolExecutor {
182
183
  );
183
184
  }
184
185
 
186
+ // Sensitive output extraction: strip directives, replace raw values
187
+ // with placeholders, and attach bindings for agent-loop substitution.
188
+ // Runs before secret detection so that raw sensitive values are already
189
+ // replaced and won't trigger entropy-based redaction.
190
+ const { sanitizedContent, bindings } = extractAndSanitize(execResult.content);
191
+ if (bindings.length > 0) {
192
+ execResult = { ...execResult, content: sanitizedContent, sensitiveBindings: bindings };
193
+ }
194
+
185
195
  // Secret detection on tool output
186
196
  const secretResult = await this.secretDetectionHandler.handle(
187
197
  execResult, name, input, context, executionTarget,
@@ -193,6 +203,8 @@ export class ToolExecutor {
193
203
  execResult = secretResult.result;
194
204
 
195
205
  const durationMs = Date.now() - startTime;
206
+ // Strip sensitiveBindings from lifecycle event to prevent raw values leaking
207
+ const { sensitiveBindings: _sb, ...safeResult } = execResult;
196
208
  emitLifecycleEvent(context, {
197
209
  type: 'executed',
198
210
  toolName: name,
@@ -205,7 +217,7 @@ export class ToolExecutor {
205
217
  riskLevel,
206
218
  decision,
207
219
  durationMs,
208
- result: execResult,
220
+ result: safeResult,
209
221
  });
210
222
 
211
223
  void getHookManager().trigger('post-tool-execute', {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Sensitive output placeholder extraction and substitution.
3
+ *
4
+ * Tool outputs may contain `<vellum-sensitive-output kind="..." value="..." />`
5
+ * directives. This module:
6
+ * 1. Parses and strips those directives from tool output.
7
+ * 2. Replaces any raw sensitive values remaining in the output with stable,
8
+ * high-uniqueness placeholders so the LLM never sees the real values.
9
+ * 3. Returns bindings (placeholder -> real value) for deterministic
10
+ * post-generation substitution in the agent loop.
11
+ *
12
+ * Raw sensitive values MUST NOT be logged or emitted in lifecycle events.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type SensitiveOutputKind = 'invite_code';
20
+
21
+ export interface SensitiveOutputBinding {
22
+ kind: SensitiveOutputKind;
23
+ placeholder: string;
24
+ value: string;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Directive regex
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const DIRECTIVE_RE =
32
+ /<vellum-sensitive-output\s+kind="([^"]+)"\s+value="([^"]+)"\s*\/>/g;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Placeholder generation
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const KIND_PREFIX: Record<SensitiveOutputKind, string> = {
39
+ invite_code: 'VELLUM_ASSISTANT_INVITE_CODE_',
40
+ };
41
+
42
+ const VALID_KINDS = new Set<string>(Object.keys(KIND_PREFIX));
43
+
44
+ /**
45
+ * Generate an 8-char uppercase base-36 short ID.
46
+ * Provides ~41 bits of entropy — sufficient for intra-request uniqueness.
47
+ */
48
+ function generateShortId(): string {
49
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
50
+ let id = '';
51
+ for (let i = 0; i < 8; i++) {
52
+ id += chars[Math.floor(Math.random() * chars.length)];
53
+ }
54
+ return id;
55
+ }
56
+
57
+ function makePlaceholder(kind: SensitiveOutputKind): string {
58
+ return `${KIND_PREFIX[kind]}${generateShortId()}`;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public API
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface SanitizeResult {
66
+ sanitizedContent: string;
67
+ bindings: SensitiveOutputBinding[];
68
+ }
69
+
70
+ /**
71
+ * Extract `<vellum-sensitive-output>` directives from tool output content,
72
+ * strip them, replace any remaining occurrences of the raw sensitive values
73
+ * with placeholders, and return the bindings for downstream substitution.
74
+ *
75
+ * Guarantees:
76
+ * - Directives are fully removed from the returned content.
77
+ * - Empty values are silently dropped.
78
+ * - Duplicate values produce a single binding (same placeholder).
79
+ * - Unknown kinds are silently ignored.
80
+ */
81
+ export function extractAndSanitize(content: string): SanitizeResult {
82
+ const bindings: SensitiveOutputBinding[] = [];
83
+ const seenValues = new Map<string, SensitiveOutputBinding>();
84
+
85
+ // Step 1: parse directives
86
+ // Reset lastIndex for safety since the regex is global
87
+ DIRECTIVE_RE.lastIndex = 0;
88
+ let match: RegExpExecArray | undefined;
89
+ while ((match = DIRECTIVE_RE.exec(content) ?? undefined) !== undefined) {
90
+ const kind = match[1];
91
+ const value = match[2];
92
+
93
+ if (!value || value.trim().length === 0) continue;
94
+ if (!VALID_KINDS.has(kind)) continue;
95
+
96
+ const typedKind = kind as SensitiveOutputKind;
97
+ if (!seenValues.has(value)) {
98
+ const binding: SensitiveOutputBinding = {
99
+ kind: typedKind,
100
+ placeholder: makePlaceholder(typedKind),
101
+ value,
102
+ };
103
+ bindings.push(binding);
104
+ seenValues.set(value, binding);
105
+ }
106
+ }
107
+
108
+ if (bindings.length === 0) {
109
+ return { sanitizedContent: content, bindings: [] };
110
+ }
111
+
112
+ // Step 2: strip directive tags
113
+ let sanitized = content.replace(DIRECTIVE_RE, '');
114
+
115
+ // Step 3: replace raw values with placeholders throughout remaining content
116
+ for (const binding of bindings) {
117
+ sanitized = sanitized.split(binding.value).join(binding.placeholder);
118
+ }
119
+
120
+ return { sanitizedContent: sanitized, bindings };
121
+ }
122
+
123
+ /**
124
+ * Apply placeholder->value substitution to a text string.
125
+ * Used by the agent loop to resolve placeholders in streamed deltas
126
+ * and final message content.
127
+ */
128
+ export function applySubstitutions(
129
+ text: string,
130
+ substitutionMap: ReadonlyMap<string, string>,
131
+ ): string {
132
+ if (substitutionMap.size === 0) return text;
133
+
134
+ let result = text;
135
+ for (const [placeholder, value] of substitutionMap) {
136
+ result = result.split(placeholder).join(value);
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Chunk-safe substitution for streaming text deltas.
143
+ *
144
+ * Because a placeholder like `VELLUM_ASSISTANT_INVITE_CODE_AB12CD34` may be
145
+ * split across consecutive streamed chunks, this function buffers a trailing
146
+ * segment that could be the start of an incomplete placeholder and returns it
147
+ * as `pending`. The caller must prepend `pending` to the next chunk.
148
+ *
149
+ * Returns `{ emit, pending }`:
150
+ * - `emit`: text safe to send to the client (all complete placeholders resolved).
151
+ * - `pending`: trailing text that might be an incomplete placeholder prefix.
152
+ */
153
+ export function applyStreamingSubstitution(
154
+ text: string,
155
+ substitutionMap: ReadonlyMap<string, string>,
156
+ ): { emit: string; pending: string } {
157
+ if (substitutionMap.size === 0) {
158
+ return { emit: text, pending: '' };
159
+ }
160
+
161
+ // First, resolve any complete placeholders
162
+ let resolved = text;
163
+ for (const [placeholder, value] of substitutionMap) {
164
+ resolved = resolved.split(placeholder).join(value);
165
+ }
166
+
167
+ // Check if the tail of resolved text could be an incomplete placeholder prefix.
168
+ // All current placeholders start with "VELLUM_ASSISTANT_".
169
+ const PREFIX = 'VELLUM_ASSISTANT_';
170
+ const minSuffixLen = 1; // At minimum, one char of the prefix
171
+
172
+ // Walk backwards from the end to find a trailing partial match of any placeholder prefix
173
+ let pendingStart = resolved.length;
174
+ for (let i = Math.max(0, resolved.length - getMaxPlaceholderLength(substitutionMap)); i < resolved.length; i++) {
175
+ const tail = resolved.slice(i);
176
+ // Check if any placeholder starts with this tail
177
+ if (tail.length >= minSuffixLen && PREFIX.startsWith(tail)) {
178
+ pendingStart = i;
179
+ break;
180
+ }
181
+ // Also check if any full placeholder key starts with this tail
182
+ for (const placeholder of substitutionMap.keys()) {
183
+ if (placeholder.startsWith(tail) && tail.length < placeholder.length) {
184
+ pendingStart = i;
185
+ break;
186
+ }
187
+ }
188
+ if (pendingStart !== resolved.length) break;
189
+ }
190
+
191
+ return {
192
+ emit: resolved.slice(0, pendingStart),
193
+ pending: resolved.slice(pendingStart),
194
+ };
195
+ }
196
+
197
+ function getMaxPlaceholderLength(map: ReadonlyMap<string, string>): number {
198
+ let max = 0;
199
+ for (const key of map.keys()) {
200
+ if (key.length > max) max = key.length;
201
+ }
202
+ return max;
203
+ }
@@ -1,4 +1,5 @@
1
1
  import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
2
+ import { createOrReuseToolGrantRequest } from '../runtime/tool-grant-request-helper.js';
2
3
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
3
4
  import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
4
5
  import { getLogger } from '../util/logger.js';
@@ -266,7 +267,48 @@ export class ToolApprovalHandler {
266
267
  }
267
268
 
268
269
  // No matching grant or race condition — deny.
269
- const reason = guardianApprovalDeniedMessage(context.guardianActorRole, name);
270
+ //
271
+ // For verified non-guardian actors with sufficient context, escalate to
272
+ // the guardian by creating a canonical tool_grant_request. Unverified
273
+ // actors remain fail-closed with no escalation.
274
+ let escalationMessage: string | undefined;
275
+ if (
276
+ context.guardianActorRole === 'non-guardian'
277
+ && context.assistantId
278
+ && context.executionChannel
279
+ && context.requesterExternalUserId
280
+ ) {
281
+ const inputDigest = deferredConsumeParams?.inputDigest
282
+ ?? computeToolApprovalDigest(name, input);
283
+ const escalation = createOrReuseToolGrantRequest({
284
+ assistantId: context.assistantId,
285
+ sourceChannel: context.executionChannel as import('../channels/types.js').ChannelId,
286
+ conversationId: context.conversationId,
287
+ requesterExternalUserId: context.requesterExternalUserId,
288
+ requesterChatId: context.requesterChatId,
289
+ toolName: name,
290
+ inputDigest,
291
+ questionText: `Trusted contact is requesting permission to use "${name}"`,
292
+ });
293
+
294
+ if ('created' in escalation) {
295
+ const codeSuffix = escalation.requestCode
296
+ ? ` (request code: ${escalation.requestCode})`
297
+ : '';
298
+ escalationMessage = `Permission denied for "${name}": this action requires guardian approval. `
299
+ + `A request has been sent to the guardian${codeSuffix}. `
300
+ + `Please retry after the guardian approves.`;
301
+ } else if ('deduped' in escalation) {
302
+ const codeSuffix = escalation.requestCode
303
+ ? ` (request code: ${escalation.requestCode})`
304
+ : '';
305
+ escalationMessage = `Permission denied for "${name}": guardian approval is already pending${codeSuffix}. `
306
+ + `Please retry after the guardian approves.`;
307
+ }
308
+ // If escalation.failed, fall through to generic denial message.
309
+ }
310
+
311
+ const reason = escalationMessage ?? guardianApprovalDeniedMessage(context.guardianActorRole, name);
270
312
  log.warn({
271
313
  toolName: name,
272
314
  sessionId: context.sessionId,
@@ -275,6 +317,7 @@ export class ToolApprovalHandler {
275
317
  executionTarget,
276
318
  reason: 'guardian_approval_required',
277
319
  grantMissReason: grantResult.reason,
320
+ escalated: !!escalationMessage,
278
321
  }, 'Guardian approval gate blocked untrusted actor tool invocation (no matching grant)');
279
322
  const durationMs = Date.now() - startTime;
280
323
  emitLifecycleEvent({
@@ -1,6 +1,7 @@
1
1
  import type { SecretPromptResult } from '../permissions/secret-prompter.js';
2
2
  import type { AllowlistOption, RiskLevel, ScopeOption } from '../permissions/types.js';
3
3
  import type { ContentBlock,ToolDefinition } from '../providers/types.js';
4
+ import type { SensitiveOutputBinding } from './sensitive-output-placeholders.js';
4
5
 
5
6
  export type ExecutionTarget = 'sandbox' | 'host';
6
7
 
@@ -144,6 +145,8 @@ export interface ToolContext {
144
145
  callSessionId?: string;
145
146
  /** External user ID of the requester (non-guardian actor). Used for scoped grant consumption. */
146
147
  requesterExternalUserId?: string;
148
+ /** Chat ID of the requester (non-guardian actor). Used for tool grant request escalation notifications. */
149
+ requesterChatId?: string;
147
150
  }
148
151
 
149
152
  export interface DiffInfo {
@@ -161,6 +164,14 @@ export interface ToolExecutionResult {
161
164
  status?: string;
162
165
  /** Optional rich content blocks (e.g. images) to include alongside text in the tool result. */
163
166
  contentBlocks?: ContentBlock[];
167
+ /**
168
+ * Runtime-internal sensitive output bindings (placeholder -> real value).
169
+ * Populated by the executor when tool output contains
170
+ * `<vellum-sensitive-output>` directives. The agent loop merges these
171
+ * into a per-run substitution map for deterministic post-generation
172
+ * replacement. MUST NOT be emitted in client-facing events or logs.
173
+ */
174
+ sensitiveBindings?: SensitiveOutputBinding[];
164
175
  }
165
176
 
166
177
  export interface Tool {
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * Resolve the path to a bundled asset directory, handling compiled Bun binaries
6
+ * where `import.meta.dirname` points to the `/$bunfs/` virtual filesystem and
7
+ * non-JS files (.md, .html, .json, etc.) are not embedded.
8
+ *
9
+ * Falls back to:
10
+ * 1. `Contents/Resources/<bundleName>` (macOS .app bundle)
11
+ * 2. `<execDir>/<bundleName>` (next to the binary, non-app-bundle deployments)
12
+ * 3. Original resolved path (source mode, or last resort)
13
+ *
14
+ * This matches the pattern established by bundled-skills and WASM resolution.
15
+ *
16
+ * @param callerDir `import.meta.dirname ?? __dirname` from the call site
17
+ * @param relativePath Relative path from the source file (used in source/dev mode)
18
+ * @param bundleName Name of the asset directory in the app bundle
19
+ */
20
+ export function resolveBundledDir(callerDir: string, relativePath: string, bundleName: string): string {
21
+ if (callerDir.startsWith('/$bunfs/')) {
22
+ const execDir = dirname(process.execPath);
23
+ // macOS .app bundle: binary in Contents/MacOS/, resources in Contents/Resources/
24
+ const resourcesPath = join(execDir, '..', 'Resources', bundleName);
25
+ if (existsSync(resourcesPath)) return resourcesPath;
26
+ // Next to the binary itself (non-app-bundle deployments)
27
+ const execDirPath = join(execDir, bundleName);
28
+ if (existsSync(execDirPath)) return execDirPath;
29
+ }
30
+ return join(callerDir, relativePath);
31
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Channel-agnostic inbound identity canonicalization.
3
+ *
4
+ * Normalizes raw sender identifiers into a stable canonical form so that
5
+ * trust lookups, member matching, and guardian binding comparisons are
6
+ * immune to formatting variance across channels.
7
+ *
8
+ * Phone-like channels (sms, voice, whatsapp) normalize to E.164 using the
9
+ * existing phone utilities. Non-phone channels (telegram, slack, etc.)
10
+ * pass through the platform-stable ID as-is after whitespace trimming.
11
+ */
12
+
13
+ import type { ChannelId } from '../channels/types.js';
14
+ import { normalizePhoneNumber } from './phone.js';
15
+
16
+ /** Channels whose raw sender IDs are phone numbers. */
17
+ const PHONE_CHANNELS: ReadonlySet<ChannelId> = new Set(['sms', 'voice', 'whatsapp']);
18
+
19
+ /**
20
+ * Canonicalize a raw inbound sender identity for the given channel.
21
+ *
22
+ * - For phone-like channels: attempts E.164 normalization. Returns the
23
+ * normalized E.164 string on success, or the trimmed raw ID if
24
+ * normalization fails (defensive: don't discard an identity just because
25
+ * it doesn't parse as a phone number).
26
+ * - For non-phone channels: returns the trimmed raw ID unchanged (these
27
+ * platforms provide stable, unique identifiers that don't need normalization).
28
+ *
29
+ * Returns `null` only when `rawId` is empty/whitespace-only.
30
+ */
31
+ export function canonicalizeInboundIdentity(channel: ChannelId, rawId: string): string | null {
32
+ const trimmed = rawId.trim();
33
+ if (trimmed.length === 0) return null;
34
+
35
+ if (PHONE_CHANNELS.has(channel)) {
36
+ const e164 = normalizePhoneNumber(trimmed);
37
+ // Defensive: if normalization fails, preserve the raw ID so downstream
38
+ // lookups don't silently lose the identity.
39
+ return e164 ?? trimmed;
40
+ }
41
+
42
+ return trimmed;
43
+ }
44
+
45
+ /**
46
+ * Check whether a channel uses phone-number-based identity.
47
+ * Useful for call sites that need to know whether E.164 normalization
48
+ * applies without re-importing the channel set.
49
+ */
50
+ export function isPhoneChannel(channel: ChannelId): boolean {
51
+ return PHONE_CHANNELS.has(channel);
52
+ }