@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -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 +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -101,6 +101,19 @@ const WRAPPER_PROGRAMS = new Set([
101
101
  // value of -u) as the wrapped program instead of `echo`.
102
102
  const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir']);
103
103
 
104
+ // Bare filenames that `rm` is allowed to delete at Medium risk (instead of
105
+ // High) so workspace-scoped allow rules can approve them without the
106
+ // dangerous `allowHighRisk` flag. Only matches when the args contain no
107
+ // flags and exactly one of these filenames.
108
+ const RM_SAFE_BARE_FILES = new Set(['BOOTSTRAP.md', 'UPDATES.md']);
109
+
110
+ function isRmOfKnownSafeFile(args: string[]): boolean {
111
+ if (args.length !== 1) return false;
112
+ const target = args[0];
113
+ if (target.startsWith('-') || target.includes('/')) return false;
114
+ return RM_SAFE_BARE_FILES.has(target);
115
+ }
116
+
104
117
  /**
105
118
  * Given a segment whose program is a known wrapper, return the first
106
119
  * non-flag argument (i.e. the wrapped program name). Returns `undefined`
@@ -385,6 +398,13 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
385
398
  if (HIGH_RISK_PROGRAMS.has(prog)) return RiskLevel.High;
386
399
 
387
400
  if (prog === 'rm') {
401
+ // `rm` of known safe workspace files (no flags, bare filename) is
402
+ // Medium rather than High so scope-limited allow rules can approve
403
+ // it without needing allowHighRisk, which would bypass path checks.
404
+ if (isRmOfKnownSafeFile(seg.args)) {
405
+ maxRisk = RiskLevel.Medium;
406
+ continue;
407
+ }
388
408
  return RiskLevel.High;
389
409
  }
390
410
 
@@ -402,7 +422,14 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
402
422
  }
403
423
 
404
424
  if (WRAPPER_PROGRAMS.has(prog)) {
425
+ // `command -v` and `command -V` are read-only lookups (print where
426
+ // a command lives) — don't escalate to high risk for those.
427
+ if (prog === 'command' && seg.args.length > 0 && (seg.args[0] === '-v' || seg.args[0] === '-V')) {
428
+ continue;
429
+ }
405
430
  const wrapped = getWrappedProgram(seg);
431
+ if (wrapped === 'rm') return RiskLevel.High;
432
+ if (wrapped && HIGH_RISK_PROGRAMS.has(wrapped)) return RiskLevel.High;
406
433
  if (wrapped === 'curl' || wrapped === 'wget') {
407
434
  maxRisk = RiskLevel.Medium;
408
435
  continue;
@@ -7,6 +7,8 @@
7
7
  * same approval flow can be reused across transports.
8
8
  */
9
9
 
10
+ import type { GuardianDecisionAction } from './guardian-decision-types.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Approval actions
12
14
  // ---------------------------------------------------------------------------
@@ -20,12 +22,20 @@ export interface ApprovalActionOption {
20
22
  label: string;
21
23
  }
22
24
 
23
- /** Default action options presented to users across all channels. */
24
- export const DEFAULT_APPROVAL_ACTIONS: readonly ApprovalActionOption[] = [
25
- { id: 'approve_once', label: 'Approve once' },
26
- { id: 'approve_always', label: 'Approve always' },
27
- { id: 'reject', label: 'Reject' },
28
- ] as const;
25
+ /**
26
+ * Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
27
+ * prompt payloads can be derived from the unified decision action set.
28
+ * The `action` field from GuardianDecisionAction maps to the `id` field
29
+ * on ApprovalActionOption (both are canonical action identifiers).
30
+ */
31
+ export function toApprovalActionOptions(
32
+ actions: GuardianDecisionAction[],
33
+ ): ApprovalActionOption[] {
34
+ return actions.map(a => ({
35
+ id: a.action as ApprovalAction,
36
+ label: a.label,
37
+ }));
38
+ }
29
39
 
30
40
  // ---------------------------------------------------------------------------
31
41
  // Approval prompt
@@ -17,7 +17,8 @@ import type {
17
17
  ApprovalUIMetadata,
18
18
  ChannelApprovalPrompt,
19
19
  } from './channel-approval-types.js';
20
- import { DEFAULT_APPROVAL_ACTIONS } from './channel-approval-types.js';
20
+ import { toApprovalActionOptions } from './channel-approval-types.js';
21
+ import { buildDecisionActions, buildPlainTextFallback } from './guardian-decision-types.js';
21
22
  import * as pendingInteractions from './pending-interactions.js';
22
23
 
23
24
  /** Summary of a pending interaction, used by channel approval flows. */
@@ -69,6 +70,11 @@ export function getApprovalInfoByConversation(conversationId: string): PendingAp
69
70
 
70
71
  /**
71
72
  * Internal helper: turn a PendingApprovalInfo into a ChannelApprovalPrompt.
73
+ *
74
+ * Derives actions from the shared `buildDecisionActions` builder defined in
75
+ * guardian-decision-types.ts, then maps them to the channel-facing
76
+ * `ApprovalActionOption` shape. This ensures channel button sets are always
77
+ * consistent with the unified `GuardianDecisionPrompt` type.
72
78
  */
73
79
  function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApprovalPrompt {
74
80
  const promptText = composeApprovalMessage({
@@ -76,15 +82,11 @@ function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApproval
76
82
  toolName: info.toolName,
77
83
  });
78
84
 
79
- // Hide "approve always" when persistent trust rules are disallowed for this invocation.
80
- const actions = info.persistentDecisionsAllowed === false
81
- ? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
82
- : [...DEFAULT_APPROVAL_ACTIONS];
83
-
84
- // Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
85
- const plainTextFallback = info.persistentDecisionsAllowed === false
86
- ? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
87
- : `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
85
+ const decisionActions = buildDecisionActions({
86
+ persistentDecisionsAllowed: info.persistentDecisionsAllowed,
87
+ });
88
+ const actions = toApprovalActionOptions(decisionActions);
89
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
88
90
 
89
91
  return { promptText, actions, plainTextFallback };
90
92
  }
@@ -199,6 +201,10 @@ export function handleChannelDecision(
199
201
  * Build an approval prompt that includes context about which non-guardian
200
202
  * user is requesting the action. Sent to the guardian's chat so they
201
203
  * can approve or deny on behalf of the requester.
204
+ *
205
+ * Uses the shared `buildDecisionActions` builder with `forGuardianOnBehalf`
206
+ * set to true, which excludes `approve_always` since guardians cannot
207
+ * permanently allowlist tools on behalf of requesters.
202
208
  */
203
209
  export function buildGuardianApprovalPrompt(
204
210
  info: PendingApprovalInfo,
@@ -210,11 +216,9 @@ export function buildGuardianApprovalPrompt(
210
216
  requesterIdentifier,
211
217
  });
212
218
 
213
- // Guardian approvals are always one-time decisions — "approve always"
214
- // doesn't make sense when the guardian is approving on behalf of someone else.
215
- const actions = DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always');
216
-
217
- const plainTextFallback = `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
219
+ const decisionActions = buildDecisionActions({ forGuardianOnBehalf: true });
220
+ const actions = toApprovalActionOptions(decisionActions);
221
+ const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
218
222
 
219
223
  return { promptText, actions, plainTextFallback };
220
224
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Channel invite transport abstraction.
3
+ *
4
+ * Defines a transport interface for building shareable invite links and
5
+ * extracting inbound invite tokens from channel-specific payloads. Each
6
+ * channel (Telegram, SMS, Slack, etc.) registers an adapter that knows
7
+ * how to construct deep links and parse incoming tokens for that channel.
8
+ *
9
+ * The transport layer is intentionally thin: it handles URL construction
10
+ * and token extraction only. Redemption logic lives in
11
+ * `invite-redemption-service.ts`.
12
+ */
13
+
14
+ import type { ChannelId } from '../channels/types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface InviteSharePayload {
21
+ /** The full URL the recipient can open to redeem the invite. */
22
+ url: string;
23
+ /** Human-readable text suitable for display alongside the link. */
24
+ displayText: string;
25
+ }
26
+
27
+ export interface ChannelInviteTransport {
28
+ /** The channel this transport handles. */
29
+ channel: ChannelId;
30
+
31
+ /**
32
+ * Build a shareable invite payload (URL + display text) from a raw token.
33
+ *
34
+ * The raw token is the base64url-encoded secret returned by
35
+ * `ingress-invite-store.createInvite`. The transport wraps it in a
36
+ * channel-specific deep link so the recipient can redeem the invite
37
+ * by clicking/tapping the link.
38
+ */
39
+ buildShareableInvite(params: {
40
+ rawToken: string;
41
+ sourceChannel: ChannelId;
42
+ }): InviteSharePayload;
43
+
44
+ /**
45
+ * Extract an invite token from an inbound channel message.
46
+ *
47
+ * Returns the raw token string (without the `iv_` prefix) if the
48
+ * message contains a valid invite token, or `undefined` otherwise.
49
+ */
50
+ extractInboundToken(params: {
51
+ commandIntent?: Record<string, unknown>;
52
+ content: string;
53
+ sourceMetadata?: Record<string, unknown>;
54
+ }): string | undefined;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Registry
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const registry = new Map<ChannelId, ChannelInviteTransport>();
62
+
63
+ /**
64
+ * Register a channel invite transport. Overwrites any previously registered
65
+ * transport for the same channel.
66
+ */
67
+ export function registerTransport(transport: ChannelInviteTransport): void {
68
+ registry.set(transport.channel, transport);
69
+ }
70
+
71
+ /**
72
+ * Look up the registered transport for a channel. Returns `undefined` when
73
+ * no transport has been registered for the given channel.
74
+ */
75
+ export function getTransport(channel: ChannelId): ChannelInviteTransport | undefined {
76
+ return registry.get(channel);
77
+ }
78
+
79
+ /**
80
+ * Reset the registry. Intended for tests only.
81
+ * @internal
82
+ */
83
+ export function _resetRegistry(): void {
84
+ registry.clear();
85
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Telegram channel invite transport adapter.
3
+ *
4
+ * Builds `https://t.me/<botUsername>?start=iv_<token>` deep links and
5
+ * extracts invite tokens from `/start iv_<token>` command payloads.
6
+ *
7
+ * The `iv_` prefix distinguishes invite tokens from `gv_` (guardian
8
+ * verification) tokens that use the same `/start` deep-link mechanism.
9
+ */
10
+
11
+ import type { ChannelId } from '../../channels/types.js';
12
+ import { getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
13
+ import {
14
+ type ChannelInviteTransport,
15
+ type InviteSharePayload,
16
+ registerTransport,
17
+ } from '../channel-invite-transport.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Bot username resolution
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Resolve the Telegram bot username from credential metadata, falling back
25
+ * to the TELEGRAM_BOT_USERNAME environment variable. Mirrors the resolution
26
+ * strategy used in `guardian-outbound-actions.ts`.
27
+ */
28
+ function getTelegramBotUsername(): string | undefined {
29
+ const meta = getCredentialMetadata('telegram', 'bot_token');
30
+ if (meta?.accountInfo && typeof meta.accountInfo === 'string' && meta.accountInfo.trim().length > 0) {
31
+ return meta.accountInfo.trim();
32
+ }
33
+ return process.env.TELEGRAM_BOT_USERNAME || undefined;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Token prefix
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const INVITE_TOKEN_PREFIX = 'iv_';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Transport implementation
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export const telegramInviteTransport: ChannelInviteTransport = {
47
+ channel: 'telegram' as ChannelId,
48
+
49
+ buildShareableInvite(params: {
50
+ rawToken: string;
51
+ sourceChannel: ChannelId;
52
+ }): InviteSharePayload {
53
+ const botUsername = getTelegramBotUsername();
54
+ if (!botUsername) {
55
+ throw new Error('Telegram bot username is not configured. Set up the Telegram integration first.');
56
+ }
57
+
58
+ const url = `https://t.me/${botUsername}?start=${INVITE_TOKEN_PREFIX}${params.rawToken}`;
59
+ return {
60
+ url,
61
+ displayText: `Open in Telegram: ${url}`,
62
+ };
63
+ },
64
+
65
+ extractInboundToken(params: {
66
+ commandIntent?: Record<string, unknown>;
67
+ content: string;
68
+ sourceMetadata?: Record<string, unknown>;
69
+ }): string | undefined {
70
+ // Primary path: structured command intent from the gateway.
71
+ // The gateway normalizes `/start <payload>` into
72
+ // `{ type: 'start', payload: '<payload>' }`.
73
+ if (
74
+ params.commandIntent &&
75
+ params.commandIntent.type === 'start' &&
76
+ typeof params.commandIntent.payload === 'string'
77
+ ) {
78
+ const payload = params.commandIntent.payload;
79
+ if (payload.startsWith(INVITE_TOKEN_PREFIX)) {
80
+ const token = payload.slice(INVITE_TOKEN_PREFIX.length);
81
+ // Reject empty or whitespace-only tokens
82
+ if (token.length > 0 && token.trim().length > 0) {
83
+ return token;
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ // Fallback: raw content parsing for `/start iv_<token>` messages.
90
+ // This handles cases where the gateway forwards the raw command text
91
+ // without a structured commandIntent.
92
+ const match = params.content.match(/^\/start\s+iv_(\S+)/);
93
+ if (match && match[1] && match[1].length > 0) {
94
+ return match[1];
95
+ }
96
+
97
+ return undefined;
98
+ },
99
+ };
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Auto-register on import
103
+ // ---------------------------------------------------------------------------
104
+
105
+ registerTransport(telegramInviteTransport);
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared helper for minting scoped approval grants when a guardian-action
3
+ * request is resolved with tool metadata.
4
+ *
5
+ * Used by both the channel inbound path (inbound-message-handler.ts) and
6
+ * the desktop/IPC path (session-process.ts) to ensure grants are minted
7
+ * consistently regardless of which channel the guardian answers on.
8
+ */
9
+
10
+ import { mintGrantFromDecision } from '../approvals/approval-primitive.js';
11
+ import type { GuardianActionRequest } from '../memory/guardian-action-store.js';
12
+ import { getLogger } from '../util/logger.js';
13
+ import { runApprovalConversationTurn } from './approval-conversation-turn.js';
14
+ import { parseApprovalDecision } from './channel-approval-parser.js';
15
+ import type { ApprovalConversationGenerator } from './http-types.js';
16
+
17
+ const log = getLogger('guardian-action-grant-minter');
18
+
19
+ /** TTL for scoped approval grants minted on guardian-action answer resolution. */
20
+ export const GUARDIAN_ACTION_GRANT_TTL_MS = 5 * 60 * 1000;
21
+
22
+ /**
23
+ * Mint a `tool_signature` scoped grant when a guardian-action request is
24
+ * resolved and the request carries tool metadata (toolName + inputDigest).
25
+ *
26
+ * Uses two-tier classification:
27
+ * 1. Deterministic fast path via parseApprovalDecision (exact keyword match).
28
+ * 2. LLM fallback via runApprovalConversationTurn when the deterministic
29
+ * parser returns null and an approvalConversationGenerator is provided.
30
+ *
31
+ * Skips silently when:
32
+ * - The resolved request has no toolName/inputDigest (informational consult).
33
+ * - The guardian's answer is not classified as approval by either tier (fail-closed).
34
+ *
35
+ * Fails silently on error -- grant minting is best-effort and must never
36
+ * block the guardian-action answer flow.
37
+ */
38
+ export async function tryMintGuardianActionGrant(params: {
39
+ request: GuardianActionRequest;
40
+ answerText: string;
41
+ decisionChannel: string;
42
+ guardianExternalUserId?: string;
43
+ approvalConversationGenerator?: ApprovalConversationGenerator;
44
+ }): Promise<void> {
45
+ const { request, answerText, decisionChannel, guardianExternalUserId, approvalConversationGenerator } = params;
46
+
47
+ // Only mint for requests that carry tool metadata -- informational
48
+ // ASK_GUARDIAN consults without tool context do not produce grants.
49
+ if (!request.toolName || !request.inputDigest) {
50
+ return;
51
+ }
52
+
53
+ // Tier 1: Deterministic fast path -- try exact keyword matching first.
54
+ // Guardian-action invariant: grants are always one-time `tool_signature`
55
+ // scoped. We treat `approve_always` from the deterministic parser the
56
+ // same as `approve_once` -- the grant is still single-use. This keeps
57
+ // the guardian-action path aligned with the primary approval interception
58
+ // flow where guardians are limited to approve_once / reject.
59
+ const decision = parseApprovalDecision(answerText);
60
+ let isApproval = decision?.action === 'approve_once' || decision?.action === 'approve_always';
61
+
62
+ // Tier 2: LLM fallback -- when the deterministic parser found no match
63
+ // and a generator is available, delegate to the conversational engine.
64
+ // Only allow approve_once (not approve_always) to keep guardian-action
65
+ // grants strictly one-time and consistent with guardian policy.
66
+ if (!isApproval && !decision && approvalConversationGenerator) {
67
+ try {
68
+ const llmResult = await runApprovalConversationTurn(
69
+ {
70
+ toolName: request.toolName,
71
+ allowedActions: ['approve_once', 'reject'],
72
+ role: 'guardian',
73
+ pendingApprovals: [{ requestId: request.id, toolName: request.toolName }],
74
+ userMessage: answerText,
75
+ },
76
+ approvalConversationGenerator,
77
+ );
78
+
79
+ isApproval = llmResult.disposition === 'approve_once';
80
+
81
+ log.info(
82
+ {
83
+ event: 'guardian_action_grant_llm_fallback',
84
+ toolName: request.toolName,
85
+ requestId: request.id,
86
+ answerText,
87
+ llmDisposition: llmResult.disposition,
88
+ matched: isApproval,
89
+ decisionChannel,
90
+ },
91
+ `LLM fallback classifier returned disposition: ${llmResult.disposition}`,
92
+ );
93
+ } catch (err) {
94
+ // Fail-closed: generator errors must not produce grants.
95
+ log.warn(
96
+ {
97
+ event: 'guardian_action_grant_llm_fallback_error',
98
+ toolName: request.toolName,
99
+ requestId: request.id,
100
+ err,
101
+ decisionChannel,
102
+ },
103
+ 'LLM fallback classifier threw an error; treating as non-approval (fail-closed)',
104
+ );
105
+ }
106
+ }
107
+
108
+ if (!isApproval) {
109
+ log.info(
110
+ {
111
+ event: 'guardian_action_grant_skipped_no_approval',
112
+ toolName: request.toolName,
113
+ requestId: request.id,
114
+ answerText,
115
+ parsedAction: decision?.action ?? null,
116
+ decisionChannel,
117
+ },
118
+ 'Skipped grant minting: guardian answer not classified as approval',
119
+ );
120
+ return;
121
+ }
122
+
123
+ const result = mintGrantFromDecision({
124
+ assistantId: request.assistantId,
125
+ scopeMode: 'tool_signature',
126
+ toolName: request.toolName,
127
+ inputDigest: request.inputDigest,
128
+ requestChannel: request.sourceChannel,
129
+ decisionChannel,
130
+ executionChannel: null,
131
+ conversationId: request.sourceConversationId,
132
+ callSessionId: request.callSessionId,
133
+ guardianExternalUserId: guardianExternalUserId ?? null,
134
+ expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
135
+ });
136
+
137
+ if (result.ok) {
138
+ log.info(
139
+ {
140
+ event: 'guardian_action_grant_minted',
141
+ toolName: request.toolName,
142
+ requestId: request.id,
143
+ callSessionId: request.callSessionId,
144
+ decisionChannel,
145
+ },
146
+ 'Minted scoped approval grant for guardian-action answer resolution',
147
+ );
148
+ } else {
149
+ log.error(
150
+ { reason: result.reason, toolName: request.toolName, requestId: request.id },
151
+ 'Failed to mint scoped approval grant for guardian-action (non-fatal)',
152
+ );
153
+ }
154
+ }
@@ -26,11 +26,16 @@ export type GuardianActionMessageScenario =
26
26
  | 'guardian_followup_failed'
27
27
  | 'guardian_followup_declined_ack'
28
28
  | 'guardian_followup_clarification'
29
+ | 'guardian_pending_disambiguation'
29
30
  | 'guardian_expired_disambiguation'
30
31
  | 'guardian_followup_disambiguation'
31
32
  | 'guardian_stale_answered'
32
33
  | 'guardian_stale_expired'
33
34
  | 'guardian_stale_followup'
35
+ | 'guardian_stale_superseded'
36
+ | 'guardian_superseded_remap'
37
+ | 'guardian_unknown_code'
38
+ | 'guardian_auto_matched'
34
39
  | 'outbound_message_copy'
35
40
  | 'followup_message_sent'
36
41
  | 'followup_call_started'
@@ -48,6 +53,10 @@ export interface GuardianActionMessageContext {
48
53
  failureReason?: string;
49
54
  counterpartyPhone?: string;
50
55
  requestCodes?: string[];
56
+ /** The code the guardian provided that was not recognized. */
57
+ unknownCode?: string;
58
+ /** The code of the active request that supersedes the one the guardian targeted. */
59
+ activeRequestCode?: string;
51
60
  }
52
61
 
53
62
  export interface ComposeGuardianActionMessageOptions {
@@ -181,6 +190,11 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
181
190
  case 'guardian_followup_clarification':
182
191
  return "Sorry, I didn't quite catch that. Would you like to call them back, send them a message, or skip it for now?";
183
192
 
193
+ case 'guardian_pending_disambiguation':
194
+ return listedCodes
195
+ ? `You have multiple pending guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
196
+ : 'You have multiple pending guardian questions. Please prefix your reply with the reference code so I know which question you\'re answering.';
197
+
184
198
  case 'guardian_expired_disambiguation':
185
199
  return listedCodes
186
200
  ? `You have multiple expired guardian questions. Please prefix your reply with the reference code (${listedCodes}) so I know which question you're answering.`
@@ -200,6 +214,22 @@ export function getGuardianActionFallbackMessage(context: GuardianActionMessageC
200
214
  case 'guardian_stale_followup':
201
215
  return 'It looks like this follow-up has already been handled. No further action is needed.';
202
216
 
217
+ case 'guardian_stale_superseded':
218
+ return 'This request is no longer active. The call has ended and no further action is needed.';
219
+
220
+ case 'guardian_unknown_code':
221
+ return context.unknownCode
222
+ ? `I don't recognize the code "${context.unknownCode}". Please check the reference code and try again.`
223
+ : "I don't recognize that reference code. Please check the code and try again.";
224
+
225
+ case 'guardian_auto_matched':
226
+ return 'Got it, routing your answer to the active request.';
227
+
228
+ case 'guardian_superseded_remap':
229
+ return context.questionText
230
+ ? `Got it! Your answer has been applied to the current active request: "${context.questionText}"`
231
+ : 'Got it! Your answer has been applied to the current active request on the call.';
232
+
203
233
  case 'outbound_message_copy':
204
234
  // This SMS is sent TO the original caller relaying the guardian's answer.
205
235
  // When lateAnswerText is available, include it — that's the whole point of message_back.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared types for the guardian decision primitive.
3
+ *
4
+ * All decision entrypoints (callback buttons, conversational engine, legacy
5
+ * parser, requester self-cancel) use these types to route through the
6
+ * unified `applyGuardianDecision` primitive.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Guardian decision prompt
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Structured model for prompts shown to guardians. */
14
+ export interface GuardianDecisionPrompt {
15
+ requestId: string;
16
+ /** Short human-readable code for the request. */
17
+ requestCode: string;
18
+ state: 'pending' | 'followup_awaiting_choice' | 'expired_superseded_with_active_call';
19
+ questionText: string;
20
+ toolName: string | null;
21
+ actions: GuardianDecisionAction[];
22
+ expiresAt: number;
23
+ conversationId: string;
24
+ callSessionId: string | null;
25
+ }
26
+
27
+ export interface GuardianDecisionAction {
28
+ /** Canonical action identifier. */
29
+ action: string;
30
+ /** Human-readable label for the action. */
31
+ label: string;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Shared decision action constants
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Canonical set of all guardian decision actions with their labels. */
39
+ export const GUARDIAN_DECISION_ACTIONS = {
40
+ approve_once: { action: 'approve_once', label: 'Approve once' },
41
+ approve_always: { action: 'approve_always', label: 'Approve always' },
42
+ reject: { action: 'reject', label: 'Reject' },
43
+ } as const satisfies Record<string, GuardianDecisionAction>;
44
+
45
+ /**
46
+ * Build the set of `GuardianDecisionAction` items appropriate for a prompt,
47
+ * respecting whether persistent decisions (approve_always) are allowed.
48
+ *
49
+ * When `persistentDecisionsAllowed` is `false`, the `approve_always` action
50
+ * is excluded. When `forGuardianOnBehalf` is `true` (guardian acting on behalf
51
+ * of a requester), `approve_always` is also excluded since guardians cannot
52
+ * permanently allowlist tools on behalf of others.
53
+ */
54
+ export function buildDecisionActions(opts?: {
55
+ persistentDecisionsAllowed?: boolean;
56
+ forGuardianOnBehalf?: boolean;
57
+ }): GuardianDecisionAction[] {
58
+ const showAlways = opts?.persistentDecisionsAllowed !== false && !opts?.forGuardianOnBehalf;
59
+ return [
60
+ GUARDIAN_DECISION_ACTIONS.approve_once,
61
+ ...(showAlways ? [GUARDIAN_DECISION_ACTIONS.approve_always] : []),
62
+ GUARDIAN_DECISION_ACTIONS.reject,
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * Build the plain-text fallback instruction string that matches the given
68
+ * set of decision actions. Ensures the text always includes parser-compatible
69
+ * keywords (yes/always/no) so text-based fallback remains actionable.
70
+ */
71
+ export function buildPlainTextFallback(
72
+ promptText: string,
73
+ actions: GuardianDecisionAction[],
74
+ ): string {
75
+ const hasAlways = actions.some(a => a.action === 'approve_always');
76
+ return hasAlways
77
+ ? `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`
78
+ : `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Apply decision result
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface ApplyGuardianDecisionResult {
86
+ applied: boolean;
87
+ reason?: 'stale' | 'identity_mismatch' | 'invalid_action' | 'not_found' | 'expired';
88
+ requestId?: string;
89
+ /** Feedback text when the action was parsed from user text. */
90
+ userText?: string;
91
+ }