@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -6,31 +6,50 @@
6
6
  * keeping the constructor body focused on wiring.
7
7
  */
8
8
 
9
- import { generateAllowlistOptions, generateScopeOptions, normalizeWebFetchUrl } from '../permissions/checker.js';
10
- import type { PermissionPrompter } from '../permissions/prompter.js';
11
- import type { SecretPrompter } from '../permissions/secret-prompter.js';
12
- import { addRule, findHighestPriorityRule } from '../permissions/trust-store.js';
13
- import type { Message, ToolDefinition } from '../providers/types.js';
14
- import type { ToolExecutor } from '../tools/executor.js';
15
- import type { ToolExecutionResult, ToolLifecycleEventHandler } from '../tools/types.js';
16
- import { getLogger } from '../util/logger.js';
17
- import { isDoordashCommand, markDoordashStepInProgress } from './doordash-steps.js';
18
- import type { ServerMessage, UiSurfaceShow } from './ipc-protocol.js';
19
- import { runPostExecutionSideEffects } from './tool-side-effects.js';
20
-
21
- const log = getLogger('session-tool-setup');
22
- import { coreAppProxyTools } from '../tools/apps/definitions.js';
23
- import { registerSessionSender } from '../tools/browser/browser-screencast.js';
24
- import { requestComputerControlTool } from '../tools/computer-use/request-computer-control.js';
25
- import type { ProxyApprovalCallback, ProxyApprovalRequest } from '../tools/network/script-proxy/index.js';
26
- import { getAllToolDefinitions } from '../tools/registry.js';
27
- import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
28
- import type { GuardianRuntimeContext } from './session-runtime-assembly.js';
29
- import { projectSkillTools, type SkillProjectionCache } from './session-skill-tools.js';
30
- import type { SurfaceSessionContext } from './session-surfaces.js';
31
9
  import {
32
- surfaceProxyResolver,
33
- } from './session-surfaces.js';
10
+ generateAllowlistOptions,
11
+ generateScopeOptions,
12
+ normalizeWebFetchUrl,
13
+ } from "../permissions/checker.js";
14
+ import type { PermissionPrompter } from "../permissions/prompter.js";
15
+ import type { SecretPrompter } from "../permissions/secret-prompter.js";
16
+ import {
17
+ addRule,
18
+ findHighestPriorityRule,
19
+ } from "../permissions/trust-store.js";
20
+ import { isAllowDecision } from "../permissions/types.js";
21
+ import type { Message, ToolDefinition } from "../providers/types.js";
22
+ import { getEffectiveMode } from "../runtime/session-approval-overrides.js";
23
+ import type { ToolExecutor } from "../tools/executor.js";
24
+ import type {
25
+ ToolExecutionResult,
26
+ ToolLifecycleEventHandler,
27
+ } from "../tools/types.js";
28
+ import { getLogger } from "../util/logger.js";
29
+ import {
30
+ isDoordashCommand,
31
+ markDoordashStepInProgress,
32
+ } from "./doordash-steps.js";
33
+ import type { ServerMessage, UiSurfaceShow } from "./ipc-protocol.js";
34
+ import { runPostExecutionSideEffects } from "./tool-side-effects.js";
35
+
36
+ const log = getLogger("session-tool-setup");
37
+ import { coreAppProxyTools } from "../tools/apps/definitions.js";
38
+ import { registerSessionSender } from "../tools/browser/browser-screencast.js";
39
+ import { requestComputerControlTool } from "../tools/computer-use/request-computer-control.js";
40
+ import type {
41
+ ProxyApprovalCallback,
42
+ ProxyApprovalRequest,
43
+ } from "../tools/network/script-proxy/index.js";
44
+ import { getAllToolDefinitions } from "../tools/registry.js";
45
+ import { allUiSurfaceTools } from "../tools/ui-surface/definitions.js";
46
+ import type { GuardianRuntimeContext } from "./session-runtime-assembly.js";
47
+ import {
48
+ projectSkillTools,
49
+ type SkillProjectionCache,
50
+ } from "./session-skill-tools.js";
51
+ import type { SurfaceSessionContext } from "./session-surfaces.js";
52
+ import { surfaceProxyResolver } from "./session-surfaces.js";
34
53
 
35
54
  // ── Context Interface ────────────────────────────────────────────────
36
55
 
@@ -94,11 +113,19 @@ export function createToolExecutor(
94
113
  ctx: ToolSetupContext,
95
114
  handleToolLifecycleEvent: ToolLifecycleEventHandler,
96
115
  broadcastToAllClients?: (msg: ServerMessage) => void,
97
- ): (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<ToolExecutionResult> {
116
+ ): (
117
+ name: string,
118
+ input: Record<string, unknown>,
119
+ onOutput?: (chunk: string) => void,
120
+ ) => Promise<ToolExecutionResult> {
98
121
  // Register the session's sendToClient for browser screencast surface messages
99
122
  registerSessionSender(ctx.conversationId, (msg) => ctx.sendToClient(msg));
100
123
 
101
- return async (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => {
124
+ return async (
125
+ name: string,
126
+ input: Record<string, unknown>,
127
+ onOutput?: (chunk: string) => void,
128
+ ) => {
102
129
  if (isDoordashCommand(name, input)) {
103
130
  markDoordashStepInProgress(ctx, input);
104
131
  }
@@ -110,10 +137,11 @@ export function createToolExecutor(
110
137
  assistantId: ctx.assistantId,
111
138
  requestId: ctx.currentRequestId,
112
139
  taskRunId: ctx.taskRunId,
113
- guardianTrustClass: ctx.guardianContext?.trustClass ?? 'guardian',
140
+ guardianTrustClass: ctx.guardianContext?.trustClass ?? "guardian",
114
141
  executionChannel: ctx.guardianContext?.sourceChannel,
115
142
  callSessionId: ctx.callSessionId,
116
- triggeredBySurfaceAction: ctx.surfaceActionRequestIds?.has(ctx.currentRequestId ?? '') ?? false,
143
+ triggeredBySurfaceAction:
144
+ ctx.surfaceActionRequestIds?.has(ctx.currentRequestId ?? "") ?? false,
117
145
  requesterExternalUserId: ctx.guardianContext?.requesterExternalUserId,
118
146
  requesterChatId: ctx.guardianContext?.requesterChatId,
119
147
  onOutput,
@@ -127,7 +155,7 @@ export function createToolExecutor(
127
155
  // Tool context's sendToClient uses a loose { type: string; [key: string]: unknown }
128
156
  // signature, but at runtime these are always ServerMessage instances.
129
157
  ctx.sendToClient(msg as ServerMessage);
130
- if (msg.type === 'ui_surface_show') {
158
+ if (msg.type === "ui_surface_show") {
131
159
  const s = msg as unknown as UiSurfaceShow;
132
160
  ctx.currentTurnSurfaces.push({
133
161
  surfaceId: s.surfaceId,
@@ -140,31 +168,59 @@ export function createToolExecutor(
140
168
  }
141
169
  },
142
170
  isInteractive: !ctx.hasNoClient && !ctx.headlessLock,
143
- proxyToolResolver: (toolName: string, proxyInput: Record<string, unknown>) => surfaceProxyResolver(ctx, toolName, proxyInput),
171
+ proxyToolResolver: (
172
+ toolName: string,
173
+ proxyInput: Record<string, unknown>,
174
+ ) => surfaceProxyResolver(ctx, toolName, proxyInput),
144
175
  proxyApprovalCallback: createProxyApprovalCallback(prompter, ctx),
145
176
  requestSecret: async (params) => {
146
177
  return secretPrompter.prompt(
147
- params.service, params.field, params.label,
148
- params.description, params.placeholder,
178
+ params.service,
179
+ params.field,
180
+ params.label,
181
+ params.description,
182
+ params.placeholder,
149
183
  ctx.conversationId,
150
- params.purpose, params.allowedTools, params.allowedDomains,
184
+ params.purpose,
185
+ params.allowedTools,
186
+ params.allowedDomains,
151
187
  );
152
188
  },
153
189
  requestConfirmation: async (req) => {
154
190
  // Check trust store before prompting
155
191
  const existingRule = findHighestPriorityRule(
156
- 'cc:' + req.toolName,
157
- [req.toolName, `cc:${req.toolName}`, 'cc:*'],
192
+ "cc:" + req.toolName,
193
+ [req.toolName, `cc:${req.toolName}`, "cc:*"],
158
194
  ctx.workingDir,
159
195
  );
160
- if (existingRule && existingRule.decision !== 'ask') {
196
+ if (existingRule && existingRule.decision !== "ask") {
161
197
  return {
162
- decision: existingRule.decision === 'allow' ? 'allow' as const : 'deny' as const,
198
+ decision:
199
+ existingRule.decision === "allow"
200
+ ? ("allow" as const)
201
+ : ("deny" as const),
163
202
  };
164
203
  }
204
+ // Auto-approve sub-tool confirmations when a temporary approval
205
+ // override is active for this conversation (guardian only).
206
+ const guardianTrust = ctx.guardianContext?.trustClass ?? "guardian";
207
+ if (
208
+ guardianTrust === "guardian" &&
209
+ getEffectiveMode(ctx.conversationId) !== undefined
210
+ ) {
211
+ return { decision: "allow" as const };
212
+ }
165
213
  const allowlistOptions = [
166
- { label: `cc:${req.toolName}`, description: `Claude Code ${req.toolName}`, pattern: `cc:${req.toolName}` },
167
- { label: 'cc:*', description: 'All Claude Code sub-tools', pattern: 'cc:*' },
214
+ {
215
+ label: `cc:${req.toolName}`,
216
+ description: `Claude Code ${req.toolName}`,
217
+ pattern: `cc:${req.toolName}`,
218
+ },
219
+ {
220
+ label: "cc:*",
221
+ description: "All Claude Code sub-tools",
222
+ pattern: "cc:*",
223
+ },
168
224
  ];
169
225
  const scopeOptions = generateScopeOptions(ctx.workingDir);
170
226
  const response = await prompter.prompt(
@@ -173,26 +229,69 @@ export function createToolExecutor(
173
229
  req.riskLevel,
174
230
  allowlistOptions,
175
231
  scopeOptions,
176
- undefined, undefined,
232
+ undefined,
233
+ undefined,
177
234
  ctx.conversationId,
178
235
  req.executionTarget,
179
236
  );
180
- if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
181
- log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
182
- addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
183
- response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
237
+ if (
238
+ (response.decision === "always_allow" ||
239
+ response.decision === "always_allow_high_risk") &&
240
+ response.selectedPattern &&
241
+ response.selectedScope
242
+ ) {
243
+ log.info(
244
+ {
245
+ toolName: "cc:" + req.toolName,
246
+ pattern: response.selectedPattern,
247
+ scope: response.selectedScope,
248
+ highRisk: response.decision === "always_allow_high_risk",
249
+ },
250
+ "Persisting always-allow trust rule",
251
+ );
252
+ addRule(
253
+ "cc:" + req.toolName,
254
+ response.selectedPattern,
255
+ response.selectedScope,
256
+ "allow",
257
+ 100,
258
+ response.decision === "always_allow_high_risk"
259
+ ? { allowHighRisk: true }
260
+ : undefined,
261
+ );
184
262
  }
185
- if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
186
- log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule');
187
- addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'deny');
263
+ if (
264
+ response.decision === "always_deny" &&
265
+ response.selectedPattern &&
266
+ response.selectedScope
267
+ ) {
268
+ log.info(
269
+ {
270
+ toolName: "cc:" + req.toolName,
271
+ pattern: response.selectedPattern,
272
+ scope: response.selectedScope,
273
+ },
274
+ "Persisting always-deny trust rule",
275
+ );
276
+ addRule(
277
+ "cc:" + req.toolName,
278
+ response.selectedPattern,
279
+ response.selectedScope,
280
+ "deny",
281
+ );
188
282
  }
189
283
  return {
190
- decision: (response.decision === 'allow' || response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') ? 'allow' as const : 'deny' as const,
284
+ decision: isAllowDecision(response.decision)
285
+ ? ("allow" as const)
286
+ : ("deny" as const),
191
287
  };
192
288
  },
193
289
  });
194
290
 
195
- runPostExecutionSideEffects(name, input, result, { ctx, broadcastToAllClients });
291
+ runPostExecutionSideEffects(name, input, result, {
292
+ ctx,
293
+ broadcastToAllClients,
294
+ });
196
295
 
197
296
  return result;
198
297
  };
@@ -216,19 +315,20 @@ export function createProxyApprovalCallback(
216
315
 
217
316
  // Use the standard network_request tool name so trust rules align with
218
317
  // the checker's URL-based candidate generation and allowlist options.
219
- const toolName = 'network_request';
318
+ const toolName = "network_request";
220
319
  const { scheme } = decision.target;
221
- const url = `${scheme}://${hostname}${port ? ':' + port : ''}${path}`;
320
+ const url = `${scheme}://${hostname}${port ? ":" + port : ""}${path}`;
222
321
 
223
322
  const input: Record<string, unknown> = {
224
323
  url,
225
324
  proxy_session_id: request.sessionId,
226
325
  };
227
- if (decision.kind === 'ask_missing_credential') {
326
+ if (decision.kind === "ask_missing_credential") {
228
327
  input.matching_patterns = decision.matchingPatterns;
229
328
  }
230
329
 
231
- const riskLevel = decision.kind === 'ask_missing_credential' ? 'high' : 'medium';
330
+ const riskLevel =
331
+ decision.kind === "ask_missing_credential" ? "high" : "medium";
232
332
 
233
333
  // Check trust store before prompting — build candidates that mirror
234
334
  // buildCommandCandidates() in checker.ts for network_request.
@@ -242,16 +342,23 @@ export function createProxyApprovalCallback(
242
342
  // Deduplicate
243
343
  const uniqueCandidates = [...new Set(candidates)];
244
344
 
245
- const existingRule = findHighestPriorityRule(toolName, uniqueCandidates, ctx.workingDir);
246
- if (existingRule && existingRule.decision !== 'ask') {
247
- if (existingRule.decision === 'deny') return false;
345
+ const existingRule = findHighestPriorityRule(
346
+ toolName,
347
+ uniqueCandidates,
348
+ ctx.workingDir,
349
+ );
350
+ if (existingRule && existingRule.decision !== "ask") {
351
+ if (existingRule.decision === "deny") return false;
248
352
  // For high-risk proxy decisions, a plain allow rule (without allowHighRisk)
249
353
  // must fall through to prompting — mirroring the checker's behavior.
250
- if (riskLevel !== 'high' || existingRule.allowHighRisk === true) return true;
354
+ if (riskLevel !== "high" || existingRule.allowHighRisk === true)
355
+ return true;
251
356
  }
252
357
 
253
358
  // Use the checker's built-in allowlist generation for network_request
254
- const allowlistOptions = await generateAllowlistOptions('network_request', { url });
359
+ const allowlistOptions = await generateAllowlistOptions("network_request", {
360
+ url,
361
+ });
255
362
 
256
363
  const scopeOptions = generateScopeOptions(ctx.workingDir);
257
364
 
@@ -261,6 +368,11 @@ export function createProxyApprovalCallback(
261
368
  return false;
262
369
  }
263
370
 
371
+ // Proxied network requests require per-invocation approval and must
372
+ // not be auto-approved by temporary overrides (allow_10m / allow_thread).
373
+ // Unlike regular tool invocations, these represent outbound network
374
+ // actions that should always receive explicit confirmation.
375
+
264
376
  const response = await prompter.prompt(
265
377
  toolName,
266
378
  input,
@@ -273,19 +385,54 @@ export function createProxyApprovalCallback(
273
385
  );
274
386
 
275
387
  // Persist trust rule if the user chose "always allow" or "always deny"
276
- if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
277
- log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule (proxy)');
278
- addRule(toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
279
- response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
388
+ if (
389
+ (response.decision === "always_allow" ||
390
+ response.decision === "always_allow_high_risk") &&
391
+ response.selectedPattern &&
392
+ response.selectedScope
393
+ ) {
394
+ log.info(
395
+ {
396
+ toolName,
397
+ pattern: response.selectedPattern,
398
+ scope: response.selectedScope,
399
+ highRisk: response.decision === "always_allow_high_risk",
400
+ },
401
+ "Persisting always-allow trust rule (proxy)",
402
+ );
403
+ addRule(
404
+ toolName,
405
+ response.selectedPattern,
406
+ response.selectedScope,
407
+ "allow",
408
+ 100,
409
+ response.decision === "always_allow_high_risk"
410
+ ? { allowHighRisk: true }
411
+ : undefined,
412
+ );
280
413
  }
281
- if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
282
- log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule (proxy)');
283
- addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
414
+ if (
415
+ response.decision === "always_deny" &&
416
+ response.selectedPattern &&
417
+ response.selectedScope
418
+ ) {
419
+ log.info(
420
+ {
421
+ toolName,
422
+ pattern: response.selectedPattern,
423
+ scope: response.selectedScope,
424
+ },
425
+ "Persisting always-deny trust rule (proxy)",
426
+ );
427
+ addRule(
428
+ toolName,
429
+ response.selectedPattern,
430
+ response.selectedScope,
431
+ "deny",
432
+ );
284
433
  }
285
434
 
286
- return response.decision === 'allow'
287
- || response.decision === 'always_allow'
288
- || response.decision === 'always_allow_high_risk';
435
+ return isAllowDecision(response.decision);
289
436
  };
290
437
  }
291
438
 
@@ -296,7 +443,7 @@ export function createProxyApprovalCallback(
296
443
  * history or explicit preactivation. Without this, their tools are
297
444
  * unavailable in fresh sessions until `skill_load` is called.
298
445
  */
299
- const DEFAULT_PREACTIVATED_SKILL_IDS = ['tasks', 'notifications'];
446
+ const DEFAULT_PREACTIVATED_SKILL_IDS = ["tasks", "notifications"];
300
447
 
301
448
  /**
302
449
  * Subset of Session state that the resolveTools callback reads at each
@@ -34,6 +34,8 @@ import { SecretPrompter } from '../permissions/secret-prompter.js';
34
34
  import type { UserDecision } from '../permissions/types.js';
35
35
  import type { Message } from '../providers/types.js';
36
36
  import type { Provider } from '../providers/types.js';
37
+ import type { AuthContext } from '../runtime/auth/types.js';
38
+ import * as approvalOverrides from '../runtime/session-approval-overrides.js';
37
39
  import { ToolExecutor } from '../tools/executor.js';
38
40
  import type { AssistantAttachmentDraft } from './assistant-attachments.js';
39
41
  import type { AssistantActivityState, ConfirmationStateChanged } from './ipc-contract/messages.js';
@@ -142,6 +144,7 @@ export class Session {
142
144
  /** @internal */ currentPage?: string;
143
145
  /** @internal */ channelCapabilities?: ChannelCapabilities;
144
146
  /** @internal */ guardianContext?: GuardianRuntimeContext;
147
+ /** @internal */ authContext?: AuthContext;
145
148
  /** @internal */ loadedHistoryTrustClass?: GuardianRuntimeContext['trustClass'];
146
149
  /** @internal */ voiceCallControlPrompt?: string;
147
150
  /** @internal */ assistantId?: string;
@@ -428,6 +431,7 @@ export class Session {
428
431
  }
429
432
 
430
433
  dispose(): void {
434
+ approvalOverrides.clearMode(this.conversationId);
431
435
  disposeSession(this);
432
436
  }
433
437
 
@@ -499,6 +503,12 @@ export class Session {
499
503
  decisionText?: string;
500
504
  },
501
505
  ): void {
506
+ // Guard: only proceed if the confirmation is still pending. Stale or
507
+ // already-resolved requests must not activate overrides or emit events.
508
+ if (!this.prompter.hasPendingRequest(requestId)) {
509
+ return;
510
+ }
511
+
502
512
  this.prompter.resolveConfirmation(
503
513
  requestId,
504
514
  decision,
@@ -507,6 +517,11 @@ export class Session {
507
517
  decisionContext,
508
518
  );
509
519
 
520
+ // Mode activation (setTimedMode / setThreadMode) is intentionally NOT
521
+ // done here. It is handled in permission-checker.ts where
522
+ // persistentDecisionsAllowed context is available — this prevents
523
+ // proxied bash commands from escalating into blanket auto-approval.
524
+
510
525
  // Emit authoritative confirmation state and activity transition centrally
511
526
  // so ALL callers (IPC handlers, /v1/confirm, channel bridges) get
512
527
  // consistent events without duplicating emission logic.
@@ -566,6 +581,14 @@ export class Session {
566
581
  this.guardianContext = ctx ?? undefined;
567
582
  }
568
583
 
584
+ setAuthContext(ctx: AuthContext | null): void {
585
+ this.authContext = ctx ?? undefined;
586
+ }
587
+
588
+ getAuthContext(): AuthContext | undefined {
589
+ return this.authContext;
590
+ }
591
+
569
592
  setVoiceCallControlPrompt(prompt: string | null): void {
570
593
  this.voiceCallControlPrompt = prompt ?? undefined;
571
594
  }
@@ -617,7 +640,7 @@ export class Session {
617
640
  content: string,
618
641
  userMessageId: string,
619
642
  onEvent: (msg: ServerMessage) => void,
620
- options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; titleText?: string },
643
+ options?: { skipPreMessageRollback?: boolean; isInteractive?: boolean; isUserMessage?: boolean; titleText?: string },
621
644
  ): Promise<void> {
622
645
  return runAgentLoopImpl(this, content, userMessageId, onEvent, options);
623
646
  }
@@ -20,7 +20,7 @@ export interface ToolDomainEvents {
20
20
  sessionId: string;
21
21
  requestId?: string;
22
22
  toolName: string;
23
- decision: 'allow' | 'always_allow' | 'deny' | 'always_deny';
23
+ decision: 'allow' | 'allow_10m' | 'allow_thread' | 'always_allow' | 'deny' | 'always_deny';
24
24
  riskLevel: string;
25
25
  decidedAtMs: number;
26
26
  };
@@ -1,13 +1,8 @@
1
+ import { isAllowDecision, type UserDecision } from '../permissions/types.js';
1
2
  import type { ToolLifecycleEventHandler } from '../tools/types.js';
2
3
  import type { EventBus } from './bus.js';
3
4
  import type { AssistantDomainEvents } from './domain-events.js';
4
5
 
5
- const allowDecisions = new Set(['allow', 'always_allow']);
6
-
7
- function isAllowDecision(decision: string): decision is 'allow' | 'always_allow' {
8
- return allowDecisions.has(decision);
9
- }
10
-
11
6
  export function createToolDomainEventPublisher(
12
7
  eventBus: EventBus<AssistantDomainEvents>,
13
8
  ): ToolLifecycleEventHandler {
@@ -45,13 +40,13 @@ export function createToolDomainEventPublisher(
45
40
  });
46
41
  break;
47
42
  case 'executed':
48
- if (isAllowDecision(event.decision)) {
43
+ if (isAllowDecision(event.decision as UserDecision)) {
49
44
  await eventBus.emit('tool.permission.decided', {
50
45
  conversationId: event.conversationId,
51
46
  sessionId: event.sessionId,
52
47
  requestId: event.requestId,
53
48
  toolName: event.toolName,
54
- decision: event.decision,
49
+ decision: event.decision as AssistantDomainEvents['tool.permission.decided']['decision'],
55
50
  riskLevel: event.riskLevel,
56
51
  decidedAtMs: Date.now(),
57
52
  });
@@ -69,13 +64,13 @@ export function createToolDomainEventPublisher(
69
64
  });
70
65
  break;
71
66
  case 'error':
72
- if (isAllowDecision(event.decision)) {
67
+ if (isAllowDecision(event.decision as UserDecision)) {
73
68
  await eventBus.emit('tool.permission.decided', {
74
69
  conversationId: event.conversationId,
75
70
  sessionId: event.sessionId,
76
71
  requestId: event.requestId,
77
72
  toolName: event.toolName,
78
- decision: event.decision,
73
+ decision: event.decision as AssistantDomainEvents['tool.permission.decided']['decision'],
79
74
  riskLevel: event.riskLevel,
80
75
  decidedAtMs: Date.now(),
81
76
  });
@@ -48,7 +48,7 @@
48
48
  import type { ExtensionCommand, ExtensionResponse } from '../browser-extension-relay/protocol.js';
49
49
  import { extensionRelayServer } from '../browser-extension-relay/server.js';
50
50
  import { getGatewayInternalBaseUrl } from '../config/env.js';
51
- import { readHttpToken } from '../util/platform.js';
51
+ import { isSigningKeyInitialized, mintEdgeRelayToken } from '../runtime/auth/token-service.js';
52
52
 
53
53
  // ---------------------------------------------------------------------------
54
54
  // Types
@@ -126,13 +126,14 @@ async function sendRelayCommand(command: Record<string, unknown>): Promise<Exten
126
126
  return extensionRelayServer.sendCommand(command as Omit<ExtensionCommand, 'id'>);
127
127
  }
128
128
 
129
- // Fall back to HTTP relay endpoint on the daemon
130
- const token = readHttpToken();
131
- if (!token) {
132
- throw new Error(
133
- 'Browser extension relay is not connected and no HTTP token found. Is the daemon running?',
134
- );
129
+ // Fall back to HTTP relay endpoint via the gateway.
130
+ // The gateway validates edge JWTs (aud=vellum-gateway) and mints an
131
+ // exchange token for the runtime. Without the signing key (CLI
132
+ // out-of-process), we cannot mint JWTs at all.
133
+ if (!isSigningKeyInitialized()) {
134
+ throw new Error('Auth signing key not initialized — browser-relay commands require the daemon to be running');
135
135
  }
136
+ const token = mintEdgeRelayToken();
136
137
 
137
138
  const resp = await fetch(`${getGatewayInternalBaseUrl()}/v1/browser-relay/command`, {
138
139
  method: 'POST',
@@ -193,12 +193,17 @@ export async function createDraft(
193
193
  subject: string,
194
194
  body: string,
195
195
  inReplyTo?: string,
196
+ cc?: string,
197
+ bcc?: string,
198
+ threadId?: string,
196
199
  ): Promise<GmailDraft> {
197
200
  const headers = [
198
201
  `To: ${to}`,
199
202
  `Subject: ${subject}`,
200
203
  'Content-Type: text/plain; charset=utf-8',
201
204
  ];
205
+ if (cc) headers.push(`Cc: ${cc}`);
206
+ if (bcc) headers.push(`Bcc: ${bcc}`);
202
207
  if (inReplyTo) {
203
208
  headers.push(`In-Reply-To: ${inReplyTo}`);
204
209
  headers.push(`References: ${inReplyTo}`);
@@ -208,9 +213,36 @@ export async function createDraft(
208
213
  .replace(/\+/g, '-')
209
214
  .replace(/\//g, '_')
210
215
  .replace(/=+$/, '');
216
+ const message: Record<string, unknown> = { raw };
217
+ if (threadId) message.threadId = threadId;
218
+ return request<GmailDraft>(token, '/drafts', {
219
+ method: 'POST',
220
+ body: JSON.stringify({ message }),
221
+ });
222
+ }
223
+
224
+ /** Create a draft from a pre-built base64url MIME payload. */
225
+ export async function createDraftRaw(
226
+ token: string,
227
+ raw: string,
228
+ threadId?: string,
229
+ ): Promise<GmailDraft> {
230
+ const message: Record<string, unknown> = { raw };
231
+ if (threadId) message.threadId = threadId;
211
232
  return request<GmailDraft>(token, '/drafts', {
212
233
  method: 'POST',
213
- body: JSON.stringify({ message: { raw } }),
234
+ body: JSON.stringify({ message }),
235
+ });
236
+ }
237
+
238
+ /** Send an existing draft by ID. */
239
+ export async function sendDraft(
240
+ token: string,
241
+ draftId: string,
242
+ ): Promise<GmailMessage> {
243
+ return request<GmailMessage>(token, '/drafts/send', {
244
+ method: 'POST',
245
+ body: JSON.stringify({ id: draftId }),
214
246
  });
215
247
  }
216
248
 
@@ -16,6 +16,8 @@ export interface MimeMessageOptions {
16
16
  subject: string;
17
17
  body: string;
18
18
  inReplyTo?: string;
19
+ cc?: string;
20
+ bcc?: string;
19
21
  attachments: MimeAttachment[];
20
22
  }
21
23
 
@@ -32,7 +34,7 @@ function toBase64Url(input: Buffer): string {
32
34
  * Returns a base64url-encoded string ready for Gmail's messages.send `raw` field.
33
35
  */
34
36
  export function buildMultipartMime(options: MimeMessageOptions): string {
35
- const { to, subject, body, inReplyTo, attachments } = options;
37
+ const { to, subject, body, inReplyTo, cc, bcc, attachments } = options;
36
38
  const boundary = `----=_Part_${randomBytes(16).toString('hex')}`;
37
39
 
38
40
  const headers = [
@@ -41,6 +43,8 @@ export function buildMultipartMime(options: MimeMessageOptions): string {
41
43
  'MIME-Version: 1.0',
42
44
  `Content-Type: multipart/mixed; boundary="${boundary}"`,
43
45
  ];
46
+ if (cc) headers.push(`Cc: ${cc}`);
47
+ if (bcc) headers.push(`Bcc: ${bcc}`);
44
48
  if (inReplyTo) {
45
49
  headers.push(`In-Reply-To: ${inReplyTo}`);
46
50
  headers.push(`References: ${inReplyTo}`);