@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -28,6 +28,9 @@ export interface NotificationDeliveryRow {
28
28
  conversationId: string | null;
29
29
  messageId: string | null;
30
30
  conversationStrategy: string | null;
31
+ threadAction: string | null;
32
+ threadTargetConversationId: string | null;
33
+ threadDecisionFallbackUsed: number | null;
31
34
  clientDeliveryStatus: string | null;
32
35
  clientDeliveryError: string | null;
33
36
  clientDeliveryAt: number | null;
@@ -52,6 +55,9 @@ function rowToDelivery(row: typeof notificationDeliveries.$inferSelect): Notific
52
55
  conversationId: row.conversationId,
53
56
  messageId: row.messageId,
54
57
  conversationStrategy: row.conversationStrategy,
58
+ threadAction: row.threadAction,
59
+ threadTargetConversationId: row.threadTargetConversationId,
60
+ threadDecisionFallbackUsed: row.threadDecisionFallbackUsed,
55
61
  clientDeliveryStatus: row.clientDeliveryStatus,
56
62
  clientDeliveryError: row.clientDeliveryError,
57
63
  clientDeliveryAt: row.clientDeliveryAt,
@@ -76,6 +82,9 @@ export interface CreateDeliveryParams {
76
82
  conversationId?: string;
77
83
  messageId?: string;
78
84
  conversationStrategy?: string;
85
+ threadAction?: string;
86
+ threadTargetConversationId?: string;
87
+ threadDecisionFallbackUsed?: boolean;
79
88
  }
80
89
 
81
90
  /** Create a new delivery audit record. */
@@ -99,6 +108,9 @@ export function createDelivery(params: CreateDeliveryParams): NotificationDelive
99
108
  conversationId: params.conversationId ?? null,
100
109
  messageId: params.messageId ?? null,
101
110
  conversationStrategy: params.conversationStrategy ?? null,
111
+ threadAction: params.threadAction ?? null,
112
+ threadTargetConversationId: params.threadTargetConversationId ?? null,
113
+ threadDecisionFallbackUsed: params.threadDecisionFallbackUsed != null ? (params.threadDecisionFallbackUsed ? 1 : 0) : null,
102
114
  clientDeliveryStatus: null,
103
115
  clientDeliveryError: null,
104
116
  clientDeliveryAt: null,
@@ -205,6 +205,7 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
205
205
 
206
206
  // Step 2: Evaluate the signal through the decision engine
207
207
  const connectedChannels = getConnectedChannels(assistantId);
208
+
208
209
  let decision = await evaluateSignal(signal, connectedChannels);
209
210
 
210
211
  // Step 2.5: Enforce routing intent policy (fire-time guard)
@@ -10,11 +10,10 @@
10
10
  * needs for a routing decision, not full conversation contents.
11
11
  */
12
12
 
13
- import { and, desc, eq, isNotNull } from 'drizzle-orm';
13
+ import { and, count, desc, eq, inArray, isNotNull } from 'drizzle-orm';
14
14
 
15
15
  import { getDb } from '../memory/db.js';
16
- import { countPendingByConversation } from '../memory/guardian-approvals.js';
17
- import { conversations, notificationDeliveries, notificationDecisions, notificationEvents } from '../memory/schema.js';
16
+ import { channelGuardianApprovalRequests, conversations, notificationDecisions, notificationDeliveries, notificationEvents } from '../memory/schema.js';
18
17
  import { getLogger } from '../util/logger.js';
19
18
  import type { NotificationChannel } from './types.js';
20
19
 
@@ -148,49 +147,78 @@ function buildCandidatesForChannel(
148
147
 
149
148
  seen.add(row.conversationId);
150
149
 
151
- const candidate: ThreadCandidate = {
150
+ candidates.push({
152
151
  conversationId: row.conversationId,
153
152
  title: row.convTitle,
154
153
  updatedAt: row.convUpdatedAt,
155
154
  latestSourceEventName: row.sourceEventName ?? null,
156
155
  channel: channel,
157
- };
158
-
159
- // Enrich with guardian context
160
- const guardianContext = buildGuardianContext(row.conversationId, assistantId);
161
- if (guardianContext) {
162
- candidate.guardianContext = guardianContext;
163
- }
164
-
165
- candidates.push(candidate);
156
+ });
166
157
 
167
158
  if (candidates.length >= MAX_CANDIDATES_PER_CHANNEL) break;
168
159
  }
169
160
 
161
+ // Batch-enrich all candidates with guardian context in a single query
162
+ if (candidates.length > 0) {
163
+ const pendingCounts = batchCountPendingByConversation(
164
+ candidates.map((c) => c.conversationId),
165
+ assistantId,
166
+ );
167
+ for (const candidate of candidates) {
168
+ const pendingCount = pendingCounts.get(candidate.conversationId) ?? 0;
169
+ if (pendingCount > 0) {
170
+ candidate.guardianContext = { pendingUnresolvedRequestCount: pendingCount };
171
+ }
172
+ }
173
+ }
174
+
170
175
  return candidates;
171
176
  }
172
177
 
173
178
  // -- Guardian context enrichment ----------------------------------------------
174
179
 
175
180
  /**
176
- * Build guardian-specific context for a candidate conversation.
177
- * Returns null when there is no guardian-relevant data.
181
+ * Batch-count pending guardian approval requests for multiple conversations
182
+ * in a single query. Returns a map from conversationId to pending count
183
+ * (only entries with count > 0 are included).
178
184
  */
179
- function buildGuardianContext(
180
- conversationId: string,
185
+ function batchCountPendingByConversation(
186
+ conversationIds: string[],
181
187
  assistantId: string,
182
- ): GuardianCandidateContext | null {
188
+ ): Map<string, number> {
189
+ const result = new Map<string, number>();
190
+ if (conversationIds.length === 0) return result;
191
+
183
192
  try {
184
- const pendingCount = countPendingByConversation(conversationId, assistantId);
185
- if (pendingCount > 0) {
186
- return { pendingUnresolvedRequestCount: pendingCount };
193
+ const db = getDb();
194
+
195
+ const rows = db
196
+ .select({
197
+ conversationId: channelGuardianApprovalRequests.conversationId,
198
+ count: count(),
199
+ })
200
+ .from(channelGuardianApprovalRequests)
201
+ .where(
202
+ and(
203
+ inArray(channelGuardianApprovalRequests.conversationId, conversationIds),
204
+ eq(channelGuardianApprovalRequests.status, 'pending'),
205
+ eq(channelGuardianApprovalRequests.assistantId, assistantId),
206
+ ),
207
+ )
208
+ .groupBy(channelGuardianApprovalRequests.conversationId)
209
+ .all();
210
+
211
+ for (const row of rows) {
212
+ if (row.count > 0) {
213
+ result.set(row.conversationId, row.count);
214
+ }
187
215
  }
188
216
  } catch (err) {
189
217
  const errMsg = err instanceof Error ? err.message : String(err);
190
- log.warn({ err: errMsg, conversationId }, 'Failed to query guardian context for candidate');
218
+ log.warn({ err: errMsg }, 'Failed to batch-query guardian context for candidates');
191
219
  }
192
220
 
193
- return null;
221
+ return result;
194
222
  }
195
223
 
196
224
  // -- Prompt serialization -----------------------------------------------------
@@ -213,13 +241,20 @@ export function serializeCandidatesForPrompt(candidateSet: ThreadCandidateSet):
213
241
 
214
242
  const lines: string[] = [`Channel: ${channel}`];
215
243
  for (const c of candidates) {
244
+ // Escape title to prevent format corruption from quotes or newlines in
245
+ // user/model-provided text. JSON.stringify produces a safe single-line
246
+ // quoted string; we strip the outer quotes since we wrap in our own.
247
+ const safeTitle = c.title
248
+ ? JSON.stringify(c.title).slice(1, -1)
249
+ : '(untitled)';
216
250
  const parts: string[] = [
217
251
  ` - id=${c.conversationId}`,
218
- `title="${c.title ?? '(untitled)'}"`,
252
+ `title="${safeTitle}"`,
219
253
  `updated=${new Date(c.updatedAt).toISOString()}`,
220
254
  ];
221
255
  if (c.latestSourceEventName) {
222
- parts.push(`lastEvent="${c.latestSourceEventName}"`);
256
+ const safeEventName = JSON.stringify(c.latestSourceEventName).slice(1, -1);
257
+ parts.push(`lastEvent="${safeEventName}"`);
223
258
  }
224
259
  if (c.guardianContext) {
225
260
  parts.push(`pendingRequests=${c.guardianContext.pendingUnresolvedRequestCount}`);
@@ -95,13 +95,14 @@ export interface ThreadActionReuseExisting {
95
95
  /** Per-channel thread action — either start a new thread or reuse an existing one. */
96
96
  export type ThreadAction = ThreadActionStartNew | ThreadActionReuseExisting;
97
97
 
98
+
98
99
  /** Output produced by the notification decision engine for a given signal. */
99
100
  export interface NotificationDecision {
100
101
  shouldNotify: boolean;
101
102
  selectedChannels: NotificationChannel[];
102
103
  reasoningSummary: string;
103
104
  renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>>;
104
- /** Per-channel thread action. When absent for a channel, defaults to start_new. */
105
+ /** Per-channel thread actions decided by the model. Absent channels default to start_new. */
105
106
  threadActions?: Partial<Record<NotificationChannel, ThreadAction>>;
106
107
  deepLinkTarget?: Record<string, unknown>;
107
108
  dedupeKey: string;
@@ -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`
@@ -123,19 +136,6 @@ function getWrappedProgram(seg: { program: string; args: string[] }): string | u
123
136
  return undefined;
124
137
  }
125
138
 
126
- function isHighRiskRm(args: string[]): boolean {
127
- // rm with -r, -rf, -fr, or targeting root/home
128
- for (const arg of args) {
129
- if (arg.startsWith('-') && (arg.includes('r') || arg.includes('f'))) {
130
- return true;
131
- }
132
- if (arg === '/' || arg === '~' || arg === '$HOME') {
133
- return true;
134
- }
135
- }
136
- return false;
137
- }
138
-
139
139
  function getStringField(input: Record<string, unknown>, ...keys: string[]): string {
140
140
  for (const key of keys) {
141
141
  const value = input[key];
@@ -398,9 +398,14 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
398
398
  if (HIGH_RISK_PROGRAMS.has(prog)) return RiskLevel.High;
399
399
 
400
400
  if (prog === 'rm') {
401
- if (isHighRiskRm(seg.args)) return RiskLevel.High;
402
- maxRisk = RiskLevel.Medium;
403
- continue;
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
+ }
408
+ return RiskLevel.High;
404
409
  }
405
410
 
406
411
  if (prog === 'chmod' || prog === 'chown' || prog === 'chgrp'
@@ -417,7 +422,14 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
417
422
  }
418
423
 
419
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
+ }
420
430
  const wrapped = getWrappedProgram(seg);
431
+ if (wrapped === 'rm') return RiskLevel.High;
432
+ if (wrapped && HIGH_RISK_PROGRAMS.has(wrapped)) return RiskLevel.High;
421
433
  if (wrapped === 'curl' || wrapped === 'wget') {
422
434
  maxRisk = RiskLevel.Medium;
423
435
  continue;
@@ -37,6 +37,13 @@ const COMPUTER_USE_TOOLS = [
37
37
  * Computed at runtime so paths reflect the configured root directory.
38
38
  */
39
39
  export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
40
+ // Some test suites mock getConfig() with partial objects; treat missing
41
+ // branches as defaults so rule generation remains deterministic.
42
+ const config = getConfig() as {
43
+ sandbox?: { enabled?: boolean };
44
+ skills?: { load?: { extraDirs?: unknown } };
45
+ };
46
+
40
47
  const hostFileRules = HOST_FILE_TOOLS.map((tool) => ({
41
48
  id: `default:ask-${tool}-global`,
42
49
  tool,
@@ -50,11 +57,11 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
50
57
  // global default ask rule uses "**" (globstar) instead of a "tool:*" prefix
51
58
  // because commands often contain "/" (e.g. "cat /etc/hosts").
52
59
  const hostShellRule: DefaultRuleTemplate = {
53
- id: 'default:ask-host_bash-global',
60
+ id: 'default:allow-host_bash-global',
54
61
  tool: 'host_bash',
55
62
  pattern: '**',
56
63
  scope: 'everywhere',
57
- decision: 'ask',
64
+ decision: 'allow',
58
65
  priority: 50,
59
66
  };
60
67
 
@@ -62,7 +69,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
62
69
  // them (including high-risk) so the user is never prompted for sandbox work.
63
70
  // Only emit this rule when the sandbox is actually enabled; otherwise bash
64
71
  // commands execute on the host and must go through normal permission checks.
65
- const sandboxEnabled = getConfig().sandbox.enabled;
72
+ const sandboxEnabled = config.sandbox?.enabled !== false;
66
73
  const sandboxShellRule: DefaultRuleTemplate | null = sandboxEnabled
67
74
  ? {
68
75
  id: 'default:allow-bash-global',
@@ -149,7 +156,10 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
149
156
 
150
157
  // Append any user-configured extra skill directories so they get the
151
158
  // same default ask rules as managed and bundled dirs.
152
- const extraDirs = getConfig().skills.load.extraDirs;
159
+ const rawExtraDirs = config.skills?.load?.extraDirs;
160
+ const extraDirs = Array.isArray(rawExtraDirs)
161
+ ? rawExtraDirs.filter((dir): dir is string => typeof dir === 'string')
162
+ : [];
153
163
  for (let i = 0; i < extraDirs.length; i++) {
154
164
  skillDirs.push({ dir: extraDirs[i].replaceAll('\\', '/'), label: `extra-${i}` });
155
165
  }
@@ -24,8 +24,8 @@ import { getGatewayInternalBaseUrl } from '../config/env.js';
24
24
  import { getOrCreateConversation } from '../memory/conversation-key-store.js';
25
25
  import {
26
26
  finalizeFollowup,
27
- getGuardianActionRequest,
28
27
  type FollowupAction,
28
+ getGuardianActionRequest,
29
29
  type GuardianActionRequest,
30
30
  } from '../memory/guardian-action-store.js';
31
31
  import { getLogger } from '../util/logger.js';
@@ -0,0 +1,97 @@
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 type { GuardianActionRequest } from '../memory/guardian-action-store.js';
11
+ import { createScopedApprovalGrant } from '../memory/scoped-approval-grants.js';
12
+ import { getLogger } from '../util/logger.js';
13
+ import { parseApprovalDecision } from './channel-approval-parser.js';
14
+
15
+ const log = getLogger('guardian-action-grant-minter');
16
+
17
+ /** TTL for scoped approval grants minted on guardian-action answer resolution. */
18
+ export const GUARDIAN_ACTION_GRANT_TTL_MS = 5 * 60 * 1000;
19
+
20
+ /**
21
+ * Mint a `tool_signature` scoped grant when a guardian-action request is
22
+ * resolved and the request carries tool metadata (toolName + inputDigest).
23
+ *
24
+ * Skips silently when:
25
+ * - The resolved request has no toolName/inputDigest (informational consult).
26
+ * - The guardian's answer is not an explicit approval (fail-closed).
27
+ *
28
+ * Fails silently on error -- grant minting is best-effort and must never
29
+ * block the guardian-action answer flow.
30
+ */
31
+ export function tryMintGuardianActionGrant(params: {
32
+ resolvedRequest: GuardianActionRequest;
33
+ answerText: string;
34
+ decisionChannel: string;
35
+ guardianExternalUserId?: string;
36
+ }): void {
37
+ const { resolvedRequest, answerText, decisionChannel, guardianExternalUserId } = params;
38
+
39
+ // Only mint for requests that carry tool metadata -- informational
40
+ // ASK_GUARDIAN consults without tool context do not produce grants.
41
+ if (!resolvedRequest.toolName || !resolvedRequest.inputDigest) {
42
+ return;
43
+ }
44
+
45
+ // Gate on explicit affirmative guardian decisions (fail-closed).
46
+ // Only mint when the deterministic parser recognises an approval keyword
47
+ // ("yes", "approve", "allow", "go ahead", etc.). Unrecognised text
48
+ // (e.g. "nope", "don't do that") is treated as non-approval and skipped,
49
+ // preventing ambiguous answers from producing grants.
50
+ const decision = parseApprovalDecision(answerText);
51
+ if (decision?.action !== 'approve_once' && decision?.action !== 'approve_always') {
52
+ log.info(
53
+ {
54
+ event: 'guardian_action_grant_skipped_no_approval',
55
+ toolName: resolvedRequest.toolName,
56
+ requestId: resolvedRequest.id,
57
+ answerText,
58
+ parsedAction: decision?.action ?? null,
59
+ decisionChannel,
60
+ },
61
+ 'Skipped grant minting: guardian answer not classified as explicit approval',
62
+ );
63
+ return;
64
+ }
65
+
66
+ try {
67
+ createScopedApprovalGrant({
68
+ assistantId: resolvedRequest.assistantId,
69
+ scopeMode: 'tool_signature',
70
+ toolName: resolvedRequest.toolName,
71
+ inputDigest: resolvedRequest.inputDigest,
72
+ requestChannel: resolvedRequest.sourceChannel,
73
+ decisionChannel,
74
+ executionChannel: null,
75
+ conversationId: resolvedRequest.sourceConversationId,
76
+ callSessionId: resolvedRequest.callSessionId,
77
+ guardianExternalUserId: guardianExternalUserId ?? null,
78
+ expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
79
+ });
80
+
81
+ log.info(
82
+ {
83
+ event: 'guardian_action_grant_minted',
84
+ toolName: resolvedRequest.toolName,
85
+ requestId: resolvedRequest.id,
86
+ callSessionId: resolvedRequest.callSessionId,
87
+ decisionChannel,
88
+ },
89
+ 'Minted scoped approval grant for guardian-action answer resolution',
90
+ );
91
+ } catch (err) {
92
+ log.error(
93
+ { err, toolName: resolvedRequest.toolName, requestId: resolvedRequest.id },
94
+ 'Failed to mint scoped approval grant for guardian-action (non-fatal)',
95
+ );
96
+ }
97
+ }
@@ -85,7 +85,6 @@ import {
85
85
  handleGetAttachmentContent,
86
86
  handleUploadAttachment,
87
87
  } from './routes/attachment-routes.js';
88
- import { handleDebug } from './routes/debug-routes.js';
89
88
  import {
90
89
  handleAnswerCall,
91
90
  handleCancelCall,
@@ -116,8 +115,19 @@ import {
116
115
  handleSearchConversations,
117
116
  handleSendMessage,
118
117
  } from './routes/conversation-routes.js';
118
+ import { handleDebug } from './routes/debug-routes.js';
119
119
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
120
120
  import { handleGetIdentity,handleHealth } from './routes/identity-routes.js';
121
+ import {
122
+ handleBlockMember,
123
+ handleCreateInvite,
124
+ handleListInvites,
125
+ handleListMembers,
126
+ handleRedeemInvite,
127
+ handleRevokeInvite,
128
+ handleRevokeMember,
129
+ handleUpsertMember,
130
+ } from './routes/ingress-routes.js';
121
131
  import {
122
132
  handleCancelOutbound,
123
133
  handleClearSlackChannelConfig,
@@ -140,16 +150,6 @@ import {
140
150
  handlePairingRequest,
141
151
  handlePairingStatus,
142
152
  } from './routes/pairing-routes.js';
143
- import {
144
- handleBlockMember,
145
- handleCreateInvite,
146
- handleListInvites,
147
- handleListMembers,
148
- handleRedeemInvite,
149
- handleRevokeInvite,
150
- handleRevokeMember,
151
- handleUpsertMember,
152
- } from './routes/ingress-routes.js';
153
153
  import { handleAddSecret } from './routes/secret-routes.js';
154
154
 
155
155
  // Re-export for consumers
@@ -6,8 +6,8 @@
6
6
  * instead of resuming an agent loop.
7
7
  */
8
8
  import {
9
- resolveApprovalRequest,
10
9
  type GuardianApprovalRequest,
10
+ resolveApprovalRequest,
11
11
  } from '../../memory/channel-guardian-store.js';
12
12
  import { getLogger } from '../../util/logger.js';
13
13
  import { createOutboundSession } from '../channel-guardian-service.js';
@@ -4,13 +4,13 @@
4
4
 
5
5
  import { statSync } from 'node:fs';
6
6
 
7
- import { getDbPath } from '../../util/platform.js';
7
+ import { getConfig } from '../../config/loader.js';
8
8
  import { countConversations } from '../../memory/conversation-store.js';
9
- import { getMemoryJobCounts } from '../../memory/jobs-store.js';
10
- import { countSchedules } from '../../schedule/schedule-store.js';
11
9
  import { rawAll } from '../../memory/db.js';
12
- import { getConfig } from '../../config/loader.js';
10
+ import { getMemoryJobCounts } from '../../memory/jobs-store.js';
13
11
  import { getProviderDebugStatus } from '../../providers/registry.js';
12
+ import { countSchedules } from '../../schedule/schedule-store.js';
13
+ import { getDbPath } from '../../util/platform.js';
14
14
 
15
15
  /** Process start time — used to calculate uptime. */
16
16
  const startedAt = Date.now();