@vellumai/assistant 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Route handlers for deterministic guardian action endpoints.
3
+ *
4
+ * These endpoints let desktop clients fetch pending guardian prompts and
5
+ * submit button decisions without relying on text parsing.
6
+ */
7
+ import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
8
+ import {
9
+ getPendingApprovalForRequest,
10
+ listPendingApprovalRequests,
11
+ } from '../../memory/channel-guardian-store.js';
12
+ import type { ApprovalAction } from '../channel-approval-types.js';
13
+ import { handleChannelDecision } from '../channel-approvals.js';
14
+ import type { GuardianDecisionPrompt } from '../guardian-decision-types.js';
15
+ import { buildDecisionActions } from '../guardian-decision-types.js';
16
+ import { httpError } from '../http-errors.js';
17
+ import * as pendingInteractions from '../pending-interactions.js';
18
+ import { handleAccessRequestDecision } from './access-request-decision.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // GET /v1/guardian-actions/pending?conversationId=...
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * List pending guardian decision prompts for a conversation.
26
+ *
27
+ * Returns guardian approval requests (from the channel guardian store) that
28
+ * are still pending, mapped to the GuardianDecisionPrompt shape so clients
29
+ * can render structured button UIs.
30
+ */
31
+ export function handleGuardianActionsPending(req: Request): Response {
32
+ const url = new URL(req.url);
33
+ const conversationId = url.searchParams.get('conversationId');
34
+
35
+ if (!conversationId) {
36
+ return httpError('BAD_REQUEST', 'conversationId query parameter is required', 400);
37
+ }
38
+
39
+ const prompts = listGuardianDecisionPrompts({ conversationId });
40
+ return Response.json({ conversationId, prompts });
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // POST /v1/guardian-actions/decision
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Submit a guardian action decision.
49
+ *
50
+ * Looks up the guardian approval by requestId and applies the decision
51
+ * through the unified guardian decision primitive.
52
+ */
53
+ export async function handleGuardianActionDecision(req: Request): Promise<Response> {
54
+ const body = await req.json() as {
55
+ requestId?: string;
56
+ action?: string;
57
+ conversationId?: string;
58
+ };
59
+
60
+ const { requestId, action, conversationId } = body;
61
+
62
+ if (!requestId || typeof requestId !== 'string') {
63
+ return httpError('BAD_REQUEST', 'requestId is required', 400);
64
+ }
65
+
66
+ if (!action || typeof action !== 'string') {
67
+ return httpError('BAD_REQUEST', 'action is required', 400);
68
+ }
69
+
70
+ const VALID_ACTIONS = new Set<string>(['approve_once', 'approve_always', 'reject']);
71
+ if (!VALID_ACTIONS.has(action)) {
72
+ return httpError('BAD_REQUEST', `Invalid action: ${action}. Must be one of: approve_once, approve_always, reject`, 400);
73
+ }
74
+
75
+ // Try the channel guardian approval store first (tool approval prompts)
76
+ const approval = getPendingApprovalForRequest(requestId);
77
+ if (approval) {
78
+ // Enforce conversationId scoping: reject decisions that target the wrong conversation.
79
+ if (conversationId && conversationId !== approval.conversationId) {
80
+ return httpError('BAD_REQUEST', 'conversationId does not match the approval', 400);
81
+ }
82
+
83
+ // Access request approvals need a separate decision path — they don't have
84
+ // pending interactions and use verification sessions instead.
85
+ if (approval.toolName === 'ingress_access_request') {
86
+ const mappedAction = action === 'reject' ? 'deny' as const : 'approve' as const;
87
+ // Use 'desktop' as the actor identity because this endpoint is
88
+ // unauthenticated — we cannot verify the caller is the assigned
89
+ // guardian, so we record a generic desktop origin instead of
90
+ // falsely attributing the decision to guardianExternalUserId.
91
+ const decisionResult = handleAccessRequestDecision(
92
+ approval,
93
+ mappedAction,
94
+ 'desktop',
95
+ );
96
+ return Response.json({
97
+ applied: decisionResult.type !== 'stale',
98
+ requestId,
99
+ reason: decisionResult.type === 'stale' ? 'stale' : undefined,
100
+ accessRequestResult: decisionResult,
101
+ });
102
+ }
103
+
104
+ // Note: actorExternalUserId is left undefined because the desktop endpoint
105
+ // does not authenticate caller identity. This means scoped grant minting is
106
+ // skipped for button-based decisions — an acceptable trade-off to avoid
107
+ // falsifying audit records with an unverified guardian identity.
108
+ const result = applyGuardianDecision({
109
+ approval,
110
+ decision: { action: action as 'approve_once' | 'approve_always' | 'reject', source: 'plain_text', requestId },
111
+ actorExternalUserId: undefined,
112
+ actorChannel: 'vellum',
113
+ });
114
+ return Response.json({ ...result, requestId: result.requestId ?? requestId });
115
+ }
116
+
117
+ // Fall back to the pending interactions tracker (direct confirmation requests).
118
+ // Route through handleChannelDecision so approve_always properly persists trust rules.
119
+ const interaction = pendingInteractions.get(requestId);
120
+ if (interaction) {
121
+ // Enforce conversationId scoping for interactions too.
122
+ if (conversationId && conversationId !== interaction.conversationId) {
123
+ return httpError('BAD_REQUEST', 'conversationId does not match the interaction', 400);
124
+ }
125
+
126
+ const result = handleChannelDecision(
127
+ interaction.conversationId,
128
+ { action: action as ApprovalAction, source: 'plain_text', requestId },
129
+ );
130
+ return Response.json({ ...result, requestId: result.requestId ?? requestId });
131
+ }
132
+
133
+ return httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Shared helper: list guardian decision prompts
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Build a list of GuardianDecisionPrompt objects for the given conversation.
142
+ *
143
+ * Aggregates pending guardian approval requests from the channel guardian
144
+ * store and pending confirmation interactions from the pending-interactions
145
+ * tracker, exposing them in a uniform shape that clients can render as
146
+ * structured button UIs.
147
+ */
148
+ export function listGuardianDecisionPrompts(params: {
149
+ conversationId: string;
150
+ }): GuardianDecisionPrompt[] {
151
+ const { conversationId } = params;
152
+ const prompts: GuardianDecisionPrompt[] = [];
153
+
154
+ // 1. Channel guardian approval requests (tool approvals routed to guardians)
155
+ const approvalRequests = listPendingApprovalRequests({
156
+ conversationId,
157
+ status: 'pending',
158
+ }).filter(a => a.expiresAt > Date.now() && a.requestId != null);
159
+
160
+ for (const approval of approvalRequests) {
161
+ const reqId = approval.requestId!;
162
+ prompts.push({
163
+ requestId: reqId,
164
+ requestCode: reqId.slice(0, 6).toUpperCase(),
165
+ state: 'pending',
166
+ questionText: approval.reason ?? `Approve tool: ${approval.toolName ?? 'unknown'}`,
167
+ toolName: approval.toolName ?? null,
168
+ actions: buildDecisionActions({ forGuardianOnBehalf: true }),
169
+ expiresAt: approval.expiresAt,
170
+ conversationId: approval.conversationId,
171
+ callSessionId: null,
172
+ });
173
+ }
174
+
175
+ // 2. Guardian action requests (voice call guardian questions) are intentionally
176
+ // excluded here — resolving them requires the answerCall + resolveGuardianActionRequest
177
+ // flow which is handled by the conversational session-process path, not by the
178
+ // deterministic button decision endpoint.
179
+ // TODO: Surface voice guardian-action requests as read-only informational prompts
180
+ // so desktop clients can see them even though they can't be resolved via buttons.
181
+
182
+ // 3. Pending confirmation interactions (direct tool approval prompts)
183
+ const interactions = pendingInteractions.getByConversation(conversationId);
184
+ for (const interaction of interactions) {
185
+ if (interaction.kind !== 'confirmation' || !interaction.confirmationDetails) continue;
186
+ // Skip if already covered by a channel guardian approval above
187
+ if (prompts.some(p => p.requestId === interaction.requestId)) continue;
188
+
189
+ const details = interaction.confirmationDetails;
190
+ prompts.push({
191
+ requestId: interaction.requestId,
192
+ requestCode: interaction.requestId.slice(0, 6).toUpperCase(),
193
+ state: 'pending',
194
+ questionText: `Approve tool: ${details.toolName}`,
195
+ toolName: details.toolName,
196
+ actions: buildDecisionActions({
197
+ persistentDecisionsAllowed: details.persistentDecisionsAllowed,
198
+ }),
199
+ expiresAt: Date.now() + 300_000,
200
+ conversationId,
201
+ callSessionId: null,
202
+ });
203
+ }
204
+
205
+ return prompts;
206
+ }
@@ -2,6 +2,7 @@
2
2
  * Approval interception: checks for pending approvals and handles inbound
3
3
  * messages as decisions, reminders, or conversational follow-ups.
4
4
  */
5
+ import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
5
6
  import type { ChannelId } from '../../channels/types.js';
6
7
  import {
7
8
  getAllPendingApprovalsByGuardianChat,
@@ -11,9 +12,7 @@ import {
11
12
  type GuardianApprovalRequest,
12
13
  updateApprovalDecision,
13
14
  } from '../../memory/channel-guardian-store.js';
14
- import { createScopedApprovalGrant } from '../../memory/scoped-approval-grants.js';
15
15
  import { emitNotificationSignal } from '../../notifications/emit-signal.js';
16
- import { computeToolApprovalDigest } from '../../security/tool-approval-digest.js';
17
16
  import { getLogger } from '../../util/logger.js';
18
17
  import { runApprovalConversationTurn } from '../approval-conversation-turn.js';
19
18
  import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
@@ -25,7 +24,6 @@ import {
25
24
  getApprovalInfoByConversation,
26
25
  getChannelApprovalPrompt,
27
26
  handleChannelDecision,
28
- type PendingApprovalInfo,
29
27
  } from '../channel-approvals.js';
30
28
  import { deliverChannelReply } from '../gateway-client.js';
31
29
  import type {
@@ -49,68 +47,6 @@ import {
49
47
 
50
48
  const log = getLogger('runtime-http');
51
49
 
52
- /** TTL for scoped approval grants minted on guardian approve_once decisions. */
53
- export const GRANT_TTL_MS = 5 * 60 * 1000;
54
-
55
- // ---------------------------------------------------------------------------
56
- // Scoped grant minting on guardian tool-approval decisions
57
- // ---------------------------------------------------------------------------
58
-
59
- /**
60
- * Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
61
- * request. Only mints when the approval info contains a tool invocation with
62
- * input (so we can compute the input digest). Informational ASK_GUARDIAN
63
- * requests that lack tool input are skipped.
64
- *
65
- * Fails silently on error — grant minting is best-effort and must never block
66
- * the approval flow.
67
- */
68
- function tryMintToolApprovalGrant(params: {
69
- approvalInfo: PendingApprovalInfo;
70
- approval: GuardianApprovalRequest;
71
- decisionChannel: ChannelId;
72
- guardianExternalUserId: string;
73
- }): void {
74
- const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
75
-
76
- // Only mint for requests that carry a tool name — the presence of toolName
77
- // distinguishes tool-approval requests from informational ones.
78
- // computeToolApprovalDigest can deterministically hash {} so zero-argument
79
- // tool invocations must still receive a grant.
80
- if (!approvalInfo.toolName) {
81
- return;
82
- }
83
-
84
- try {
85
- const inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
86
-
87
- createScopedApprovalGrant({
88
- assistantId: approval.assistantId,
89
- scopeMode: 'tool_signature',
90
- toolName: approvalInfo.toolName,
91
- inputDigest,
92
- requestChannel: approval.channel,
93
- decisionChannel,
94
- executionChannel: null,
95
- conversationId: approval.conversationId,
96
- callSessionId: null,
97
- guardianExternalUserId,
98
- requesterExternalUserId: approval.requesterExternalUserId,
99
- expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
100
- });
101
-
102
- log.info(
103
- { toolName: approvalInfo.toolName, conversationId: approval.conversationId },
104
- 'Minted scoped approval grant for guardian tool-approval decision',
105
- );
106
- } catch (err) {
107
- log.error(
108
- { err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
109
- 'Failed to mint scoped approval grant (non-fatal)',
110
- );
111
- }
112
- }
113
-
114
50
  export interface ApprovalInterceptionParams {
115
51
  conversationId: string;
116
52
  callbackData?: string;
@@ -250,13 +186,6 @@ export async function handleApprovalInterception(
250
186
  }
251
187
 
252
188
  if (callbackDecision) {
253
- // approve_always is not available for guardian approvals — guardians
254
- // should not be able to permanently allowlist tools on behalf of the
255
- // requester. Downgrade to approve_once.
256
- if (callbackDecision.action === 'approve_always') {
257
- callbackDecision = { ...callbackDecision, action: 'approve_once' };
258
- }
259
-
260
189
  // Access request approvals don't have a pending interaction in the
261
190
  // session tracker, so they need a separate decision path that creates
262
191
  // a verification session instead of resuming an agent loop.
@@ -272,44 +201,22 @@ export async function handleApprovalInterception(
272
201
  return accessResult;
273
202
  }
274
203
 
275
- // Capture pending approval info before handleChannelDecision resolves
276
- // (and removes) the pending interaction. Needed for grant minting.
277
- const cbApprovalInfo = getApprovalInfoByConversation(guardianApproval.conversationId);
278
- const cbMatchedInfo = callbackDecision.requestId
279
- ? cbApprovalInfo.find(a => a.requestId === callbackDecision!.requestId)
280
- : cbApprovalInfo[0];
281
-
282
- // Apply the decision to the underlying session using the requester's
283
- // conversation context
284
- const result = handleChannelDecision(
285
- guardianApproval.conversationId,
286
- callbackDecision,
287
- );
204
+ // Apply the decision through the unified guardian decision primitive.
205
+ // The primitive handles approve_always downgrade, approval info capture,
206
+ // record update, and scoped grant minting.
207
+ const result = applyGuardianDecision({
208
+ approval: guardianApproval,
209
+ decision: callbackDecision,
210
+ actorExternalUserId: senderExternalUserId,
211
+ actorChannel: sourceChannel,
212
+ });
288
213
 
289
214
  if (result.applied) {
290
- // Update the guardian approval request record only when the decision
291
- // was actually applied. If the request was already resolved (race with
292
- // expiry sweep or concurrent callback), skip to avoid inconsistency.
293
- const approvalStatus = callbackDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
294
- updateApprovalDecision(guardianApproval.id, {
295
- status: approvalStatus,
296
- decidedByExternalUserId: senderExternalUserId,
297
- });
298
-
299
- // Mint a scoped grant when a guardian approves a tool-approval request
300
- if (callbackDecision.action !== 'reject' && cbMatchedInfo) {
301
- tryMintToolApprovalGrant({
302
- approvalInfo: cbMatchedInfo,
303
- approval: guardianApproval,
304
- decisionChannel: sourceChannel,
305
- guardianExternalUserId: senderExternalUserId,
306
- });
307
- }
308
-
309
215
  // Notify the requester's chat about the outcome with the tool name
216
+ const effectiveAction = callbackDecision.action === 'approve_always' ? 'approve_once' : callbackDecision.action;
310
217
  const outcomeText = await composeApprovalMessageGenerative({
311
218
  scenario: 'guardian_decision_outcome',
312
- decision: callbackDecision.action === 'reject' ? 'denied' : 'approved',
219
+ decision: effectiveAction === 'reject' ? 'denied' : 'approved',
313
220
  toolName: guardianApproval.toolName,
314
221
  channel: sourceChannel,
315
222
  }, {}, approvalCopyGenerator);
@@ -428,38 +335,15 @@ export async function handleApprovalInterception(
428
335
  ...(engineResult.targetRequestId ? { requestId: engineResult.targetRequestId } : {}),
429
336
  };
430
337
 
431
- // Capture pending approval info before handleChannelDecision resolves
432
- // (and removes) the pending interaction. Needed for grant minting.
433
- const engineApprovalInfo = getApprovalInfoByConversation(targetApproval.conversationId);
434
- const engineMatchedInfo = engineDecision.requestId
435
- ? engineApprovalInfo.find(a => a.requestId === engineDecision.requestId)
436
- : engineApprovalInfo[0];
437
-
438
- const result = handleChannelDecision(
439
- targetApproval.conversationId,
440
- engineDecision,
441
- );
338
+ // Apply the decision through the unified guardian decision primitive.
339
+ const result = applyGuardianDecision({
340
+ approval: targetApproval,
341
+ decision: engineDecision,
342
+ actorExternalUserId: senderExternalUserId,
343
+ actorChannel: sourceChannel,
344
+ });
442
345
 
443
346
  if (result.applied) {
444
- // Update the guardian approval request record only when the decision
445
- // was actually applied. If the request was already resolved (race with
446
- // expiry sweep or concurrent callback), skip to avoid inconsistency.
447
- const approvalStatus = decisionAction === 'reject' ? 'denied' as const : 'approved' as const;
448
- updateApprovalDecision(targetApproval.id, {
449
- status: approvalStatus,
450
- decidedByExternalUserId: senderExternalUserId,
451
- });
452
-
453
- // Mint a scoped grant when a guardian approves a tool-approval request
454
- if (decisionAction !== 'reject' && engineMatchedInfo) {
455
- tryMintToolApprovalGrant({
456
- approvalInfo: engineMatchedInfo,
457
- approval: targetApproval,
458
- decisionChannel: sourceChannel,
459
- guardianExternalUserId: senderExternalUserId,
460
- });
461
- }
462
-
463
347
  // Notify the requester's chat about the outcome
464
348
  const outcomeText = await composeApprovalMessageGenerative({
465
349
  scenario: 'guardian_decision_outcome',
@@ -591,35 +475,15 @@ export async function handleApprovalInterception(
591
475
  return accessResult;
592
476
  }
593
477
 
594
- // Capture pending approval info before handleChannelDecision resolves
595
- // (and removes) the pending interaction. Needed for grant minting.
596
- const legacyApprovalInfo = getApprovalInfoByConversation(targetLegacyApproval.conversationId);
597
- const legacyMatchedInfo = legacyGuardianDecision.requestId
598
- ? legacyApprovalInfo.find(a => a.requestId === legacyGuardianDecision.requestId)
599
- : legacyApprovalInfo[0];
600
-
601
- const result = handleChannelDecision(
602
- targetLegacyApproval.conversationId,
603
- legacyGuardianDecision,
604
- );
478
+ // Apply the decision through the unified guardian decision primitive.
479
+ const result = applyGuardianDecision({
480
+ approval: targetLegacyApproval,
481
+ decision: legacyGuardianDecision,
482
+ actorExternalUserId: senderExternalUserId,
483
+ actorChannel: sourceChannel,
484
+ });
605
485
 
606
486
  if (result.applied) {
607
- const approvalStatus = legacyGuardianDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
608
- updateApprovalDecision(targetLegacyApproval.id, {
609
- status: approvalStatus,
610
- decidedByExternalUserId: senderExternalUserId,
611
- });
612
-
613
- // Mint a scoped grant when a guardian approves a tool-approval request
614
- if (legacyGuardianDecision.action !== 'reject' && legacyMatchedInfo) {
615
- tryMintToolApprovalGrant({
616
- approvalInfo: legacyMatchedInfo,
617
- approval: targetLegacyApproval,
618
- decisionChannel: sourceChannel,
619
- guardianExternalUserId: senderExternalUserId,
620
- });
621
- }
622
-
623
487
  // Notify the requester's chat about the outcome
624
488
  const outcomeText = await composeApprovalMessageGenerative({
625
489
  scenario: 'guardian_decision_outcome',
@@ -742,13 +606,15 @@ export async function handleApprovalInterception(
742
606
  action: 'reject',
743
607
  source: 'plain_text',
744
608
  };
745
- const cancelApplyResult = handleChannelDecision(conversationId, rejectDecision);
609
+ // Apply the cancel decision through the unified primitive.
610
+ // The primitive handles record update and (no-op) grant logic.
611
+ const cancelApplyResult = applyGuardianDecision({
612
+ approval: guardianApprovalForRequest,
613
+ decision: rejectDecision,
614
+ actorExternalUserId: senderExternalUserId,
615
+ actorChannel: sourceChannel,
616
+ });
746
617
  if (cancelApplyResult.applied) {
747
- updateApprovalDecision(guardianApprovalForRequest.id, {
748
- status: 'denied',
749
- decidedByExternalUserId: senderExternalUserId,
750
- });
751
-
752
618
  // Notify requester
753
619
  const replyText = cancelReplyText ?? await composeApprovalMessageGenerative({
754
620
  scenario: 'requester_cancel',
@@ -1152,29 +1018,39 @@ async function handleAccessRequestApproval(
1152
1018
  });
1153
1019
  }
1154
1020
 
1155
- // Emit guardian_decision (approved) signal
1156
- void emitNotificationSignal({
1157
- sourceEventName: 'ingress.trusted_contact.guardian_decision',
1158
- sourceChannel: approval.channel,
1159
- sourceSessionId: approval.conversationId,
1160
- assistantId,
1161
- attentionHints: {
1162
- requiresAction: false,
1163
- urgency: 'medium',
1164
- isAsyncBackground: false,
1165
- visibleInSourceNow: false,
1166
- },
1167
- contextPayload: {
1021
+ // Don't emit guardian_decision for approvals that still require code
1022
+ // verification — the guardian already received the code, and emitting
1023
+ // this signal prematurely causes the notification pipeline to deliver
1024
+ // a confusing "approved" message before the requester has verified.
1025
+ // The guardian_decision signal should only fire once access is fully granted
1026
+ // (i.e. after code consumption), which is handled in the verification path.
1027
+ if (!decisionResult.verificationSessionId) {
1028
+ void emitNotificationSignal({
1029
+ sourceEventName: 'ingress.trusted_contact.guardian_decision',
1168
1030
  sourceChannel: approval.channel,
1169
- requesterExternalUserId: approval.requesterExternalUserId,
1170
- requesterChatId: approval.requesterChatId,
1171
- decidedByExternalUserId,
1172
- decision: 'approved',
1173
- },
1174
- dedupeKey: `trusted-contact:guardian-decision:${approval.id}`,
1175
- });
1031
+ sourceSessionId: approval.conversationId,
1032
+ assistantId,
1033
+ attentionHints: {
1034
+ requiresAction: false,
1035
+ urgency: 'medium',
1036
+ isAsyncBackground: false,
1037
+ visibleInSourceNow: false,
1038
+ },
1039
+ contextPayload: {
1040
+ sourceChannel: approval.channel,
1041
+ requesterExternalUserId: approval.requesterExternalUserId,
1042
+ requesterChatId: approval.requesterChatId,
1043
+ decidedByExternalUserId,
1044
+ decision: 'approved',
1045
+ },
1046
+ dedupeKey: `trusted-contact:guardian-decision:${approval.id}`,
1047
+ });
1048
+ }
1176
1049
 
1177
- // Only emit verification_sent when the code was actually delivered to the guardian.
1050
+ // Emit verification_sent with visibleInSourceNow=true so the notification
1051
+ // pipeline suppresses delivery — the guardian already received the
1052
+ // verification code directly. Without this flag, the pipeline generates
1053
+ // a redundant LLM message like "Good news! Your request has been approved."
1178
1054
  if (decisionResult.verificationSessionId && codeDelivered) {
1179
1055
  void emitNotificationSignal({
1180
1056
  sourceEventName: 'ingress.trusted_contact.verification_sent',
@@ -1185,7 +1061,7 @@ async function handleAccessRequestApproval(
1185
1061
  requiresAction: false,
1186
1062
  urgency: 'low',
1187
1063
  isAsyncBackground: true,
1188
- visibleInSourceNow: false,
1064
+ visibleInSourceNow: true,
1189
1065
  },
1190
1066
  contextPayload: {
1191
1067
  sourceChannel: approval.channel,
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readFileSync, statfsSync,statSync } from 'node:fs';
6
+ import { cpus, totalmem } from 'node:os';
6
7
  import { dirname,join } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
 
@@ -36,6 +37,76 @@ function getDiskSpaceInfo(): DiskSpaceInfo | null {
36
37
  }
37
38
  }
38
39
 
40
+ interface MemoryInfo {
41
+ currentMb: number;
42
+ maxMb: number;
43
+ }
44
+
45
+ // Read the container memory limit from cgroups if available, falling back to host total.
46
+ // cgroups v2: /sys/fs/cgroup/memory.max (returns "max" when unlimited)
47
+ // cgroups v1: /sys/fs/cgroup/memory/memory.limit_in_bytes (large sentinel when unlimited)
48
+ function getContainerMemoryLimitBytes(): number | null {
49
+ try {
50
+ const v2 = readFileSync('/sys/fs/cgroup/memory.max', 'utf-8').trim();
51
+ if (v2 !== 'max') {
52
+ const bytes = parseInt(v2, 10);
53
+ if (!isNaN(bytes) && bytes > 0) return bytes;
54
+ }
55
+ } catch { /* not available */ }
56
+ try {
57
+ const v1 = readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf-8').trim();
58
+ const bytes = parseInt(v1, 10);
59
+ // cgroups v1 uses a near-INT64_MAX sentinel when no limit is set
60
+ if (!isNaN(bytes) && bytes > 0 && bytes < totalmem() * 1.5) return bytes;
61
+ } catch { /* not available */ }
62
+ return null;
63
+ }
64
+
65
+ function getMemoryInfo(): MemoryInfo {
66
+ const bytesToMb = (b: number) => Math.round((b / (1024 * 1024)) * 100) / 100;
67
+ return {
68
+ currentMb: bytesToMb(process.memoryUsage().rss),
69
+ maxMb: bytesToMb(getContainerMemoryLimitBytes() ?? totalmem()),
70
+ };
71
+ }
72
+
73
+ interface CpuInfo {
74
+ currentPercent: number;
75
+ maxCores: number;
76
+ }
77
+
78
+ // Track CPU usage over a rolling window so /healthz reports near-real-time
79
+ // utilization instead of a lifetime average (total CPU time / total uptime).
80
+ const CPU_SAMPLE_INTERVAL_MS = 5_000;
81
+ let _lastCpuUsage: NodeJS.CpuUsage = process.cpuUsage();
82
+ let _lastCpuTime: number = Date.now();
83
+ let _cachedCpuPercent = 0;
84
+
85
+ // Kick off the background sampler. unref() so it never prevents process exit.
86
+ setInterval(() => {
87
+ const now = Date.now();
88
+ const newUsage = process.cpuUsage();
89
+ const elapsedMs = now - _lastCpuTime;
90
+ if (elapsedMs > 0) {
91
+ const deltaCpuUs =
92
+ (newUsage.user - _lastCpuUsage.user) +
93
+ (newUsage.system - _lastCpuUsage.system);
94
+ const deltaCpuMs = deltaCpuUs / 1000;
95
+ const numCores = cpus().length;
96
+ _cachedCpuPercent =
97
+ Math.round((deltaCpuMs / (elapsedMs * numCores)) * 10000) / 100;
98
+ }
99
+ _lastCpuUsage = newUsage;
100
+ _lastCpuTime = now;
101
+ }, CPU_SAMPLE_INTERVAL_MS).unref();
102
+
103
+ function getCpuInfo(): CpuInfo {
104
+ return {
105
+ currentPercent: _cachedCpuPercent,
106
+ maxCores: cpus().length,
107
+ };
108
+ }
109
+
39
110
  function getPackageVersion(): string | undefined {
40
111
  try {
41
112
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
@@ -52,6 +123,8 @@ export function handleHealth(): Response {
52
123
  timestamp: new Date().toISOString(),
53
124
  version: getPackageVersion(),
54
125
  disk: getDiskSpaceInfo(),
126
+ memory: getMemoryInfo(),
127
+ cpu: getCpuInfo(),
55
128
  });
56
129
  }
57
130