@vellumai/assistant 0.3.13 → 0.3.14

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 (48) hide show
  1. package/ARCHITECTURE.md +17 -3
  2. package/README.md +2 -0
  3. package/docs/architecture/scheduling.md +81 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
  6. package/src/__tests__/channel-policy.test.ts +19 -0
  7. package/src/__tests__/guardian-control-plane-policy.test.ts +584 -0
  8. package/src/__tests__/intent-routing.test.ts +22 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  10. package/src/__tests__/notification-routing-intent.test.ts +186 -0
  11. package/src/__tests__/recording-handler.test.ts +191 -31
  12. package/src/__tests__/recording-intent-fallback.test.ts +181 -0
  13. package/src/__tests__/recording-intent-handler.test.ts +593 -73
  14. package/src/__tests__/recording-intent.test.ts +739 -343
  15. package/src/__tests__/recording-state-machine.test.ts +1109 -0
  16. package/src/__tests__/reminder-store.test.ts +20 -18
  17. package/src/__tests__/reminder.test.ts +2 -1
  18. package/src/channels/config.ts +1 -1
  19. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
  20. package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
  21. package/src/config/system-prompt.ts +5 -0
  22. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  23. package/src/daemon/handlers/misc.ts +258 -102
  24. package/src/daemon/handlers/recording.ts +417 -5
  25. package/src/daemon/handlers/sessions.ts +136 -62
  26. package/src/daemon/ipc-contract/computer-use.ts +23 -3
  27. package/src/daemon/ipc-contract/messages.ts +3 -1
  28. package/src/daemon/ipc-contract/shared.ts +6 -0
  29. package/src/daemon/ipc-contract-inventory.json +2 -0
  30. package/src/daemon/lifecycle.ts +2 -0
  31. package/src/daemon/recording-executor.ts +180 -0
  32. package/src/daemon/recording-intent-fallback.ts +132 -0
  33. package/src/daemon/recording-intent.ts +306 -15
  34. package/src/daemon/session-tool-setup.ts +4 -0
  35. package/src/notifications/README.md +69 -1
  36. package/src/notifications/adapters/sms.ts +80 -0
  37. package/src/notifications/broadcaster.ts +1 -0
  38. package/src/notifications/copy-composer.ts +3 -3
  39. package/src/notifications/decision-engine.ts +70 -1
  40. package/src/notifications/decisions-store.ts +24 -0
  41. package/src/notifications/destination-resolver.ts +2 -1
  42. package/src/notifications/emit-signal.ts +35 -3
  43. package/src/notifications/signal.ts +6 -0
  44. package/src/notifications/types.ts +3 -0
  45. package/src/schedule/scheduler.ts +15 -3
  46. package/src/tools/executor.ts +29 -0
  47. package/src/tools/guardian-control-plane-policy.ts +141 -0
  48. package/src/tools/types.ts +2 -0
@@ -18,7 +18,7 @@ import type { ModelIntent } from '../providers/types.js';
18
18
  import { getLogger } from '../util/logger.js';
19
19
  import { createDecision } from './decisions-store.js';
20
20
  import { getPreferenceSummary } from './preference-summary.js';
21
- import type { NotificationSignal } from './signal.js';
21
+ import type { NotificationSignal, RoutingIntent } from './signal.js';
22
22
  import type { NotificationChannel, NotificationDecision, RenderedChannelCopy } from './types.js';
23
23
 
24
24
  const log = getLogger('notification-decision-engine');
@@ -56,6 +56,12 @@ function buildSystemPrompt(
56
56
  `- For low-urgency background events, suppress unless they match user preferences.`,
57
57
  `- Generate a stable dedupeKey derived from the signal context so duplicate signals can be suppressed.`,
58
58
  ``,
59
+ `Routing intent (when present in the signal):`,
60
+ `- \`all_channels\`: The source explicitly requests notification on ALL connected channels.`,
61
+ `- \`multi_channel\`: The source prefers 2+ channels when 2+ are connected.`,
62
+ `- \`single_channel\`: Default routing behavior — use your best judgment (no override).`,
63
+ `When a routing intent is present, respect it in your channel selection. A post-decision guard will enforce the intent.`,
64
+ ``,
59
65
  `Copy guidelines (three distinct outputs):`,
60
66
  `- \`title\` and \`body\` are for native notification popups (e.g. vellum desktop/mobile) — keep them short and glanceable (title ≤ 8 words, body ≤ 2 sentences).`,
61
67
  `- \`deliveryText\` is the channel-native message for chat channels (e.g. telegram). It must read naturally as a standalone message.`,
@@ -91,6 +97,14 @@ function buildUserPrompt(signal: NotificationSignal): string {
91
97
  parts.push(`Deadline: ${new Date(signal.attentionHints.deadlineAt).toISOString()}`);
92
98
  }
93
99
 
100
+ if (signal.routingIntent && signal.routingIntent !== 'single_channel') {
101
+ parts.push(`Routing intent: ${signal.routingIntent}`);
102
+ }
103
+
104
+ if (signal.routingHints && Object.keys(signal.routingHints).length > 0) {
105
+ parts.push(`Routing hints: ${JSON.stringify(signal.routingHints)}`);
106
+ }
107
+
94
108
  const payloadStr = JSON.stringify(signal.contextPayload);
95
109
  if (payloadStr.length > 2) {
96
110
  parts.push(``, `Context payload:`, payloadStr);
@@ -377,6 +391,61 @@ async function classifyWithLLM(
377
391
  }
378
392
  }
379
393
 
394
+ // ── Post-decision routing intent enforcement ───────────────────────────
395
+
396
+ /**
397
+ * Enforce routing intent policy on a decision after the LLM has produced it.
398
+ * This is a fire-time guard: it overrides channel selection to match the
399
+ * routing intent specified by the signal source (e.g. a reminder).
400
+ *
401
+ * - `all_channels`: force selected channels to all connected channels.
402
+ * - `multi_channel`: ensure at least 2 channels when 2+ are connected.
403
+ * - `single_channel`: no override (default behavior).
404
+ */
405
+ export function enforceRoutingIntent(
406
+ decision: NotificationDecision,
407
+ routingIntent: RoutingIntent | undefined,
408
+ connectedChannels: NotificationChannel[],
409
+ ): NotificationDecision {
410
+ if (!routingIntent || routingIntent === 'single_channel') {
411
+ return decision;
412
+ }
413
+
414
+ if (!decision.shouldNotify) {
415
+ return decision;
416
+ }
417
+
418
+ if (routingIntent === 'all_channels') {
419
+ // Force all connected channels
420
+ if (connectedChannels.length > 0) {
421
+ const enforced = { ...decision };
422
+ enforced.selectedChannels = [...connectedChannels];
423
+ enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=all_channels enforced: ${connectedChannels.join(', ')}]`;
424
+ log.info(
425
+ { routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
426
+ 'Routing intent enforcement: all_channels → forced all connected channels',
427
+ );
428
+ return enforced;
429
+ }
430
+ }
431
+
432
+ if (routingIntent === 'multi_channel') {
433
+ // Ensure at least 2 channels when 2+ are connected
434
+ if (connectedChannels.length >= 2 && decision.selectedChannels.length < 2) {
435
+ const enforced = { ...decision };
436
+ enforced.selectedChannels = [...connectedChannels];
437
+ enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${connectedChannels.join(', ')}]`;
438
+ log.info(
439
+ { routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
440
+ 'Routing intent enforcement: multi_channel → expanded to all connected channels',
441
+ );
442
+ return enforced;
443
+ }
444
+ }
445
+
446
+ return decision;
447
+ }
448
+
380
449
  // ── Persistence ────────────────────────────────────────────────────────
381
450
 
382
451
  function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
@@ -79,6 +79,30 @@ export function createDecision(params: CreateDecisionParams): NotificationDecisi
79
79
  };
80
80
  }
81
81
 
82
+ export interface UpdateDecisionParams {
83
+ selectedChannels?: string[];
84
+ reasoningSummary?: string;
85
+ validationResults?: Record<string, unknown>;
86
+ }
87
+
88
+ /** Update an existing decision row (e.g. after routing intent enforcement). */
89
+ export function updateDecision(id: string, params: UpdateDecisionParams): void {
90
+ const db = getDb();
91
+ const updates: Record<string, unknown> = {};
92
+ if (params.selectedChannels !== undefined) {
93
+ updates.selectedChannels = JSON.stringify(params.selectedChannels);
94
+ }
95
+ if (params.reasoningSummary !== undefined) {
96
+ updates.reasoningSummary = params.reasoningSummary;
97
+ }
98
+ if (params.validationResults !== undefined) {
99
+ updates.validationResults = JSON.stringify(params.validationResults);
100
+ }
101
+ if (Object.keys(updates).length === 0) return;
102
+
103
+ db.update(notificationDecisions).set(updates).where(eq(notificationDecisions.id, id)).run();
104
+ }
105
+
82
106
  /** Fetch a single decision by ID. */
83
107
  export function getDecisionById(id: string): NotificationDecisionRow | null {
84
108
  const db = getDb();
@@ -38,7 +38,8 @@ export function resolveDestinations(
38
38
  result.set('vellum', { channel: 'vellum' });
39
39
  break;
40
40
  }
41
- case 'telegram': {
41
+ case 'telegram':
42
+ case 'sms': {
42
43
  const binding = getActiveBinding(assistantId, channel);
43
44
  if (binding) {
44
45
  result.set(channel as NotificationChannel, {
@@ -15,13 +15,15 @@ import { getDeliverableChannels } from '../channels/config.js';
15
15
  import { getActiveBinding } from '../memory/channel-guardian-store.js';
16
16
  import { getLogger } from '../util/logger.js';
17
17
  import { type BroadcastFn, VellumAdapter } from './adapters/macos.js';
18
+ import { SmsAdapter } from './adapters/sms.js';
18
19
  import { TelegramAdapter } from './adapters/telegram.js';
19
20
  import { NotificationBroadcaster,type ThreadCreatedInfo } from './broadcaster.js';
20
- import { evaluateSignal } from './decision-engine.js';
21
+ import { enforceRoutingIntent, evaluateSignal } from './decision-engine.js';
22
+ import { updateDecision } from './decisions-store.js';
21
23
  import { type DeterministicCheckContext, runDeterministicChecks } from './deterministic-checks.js';
22
24
  import { createEvent, updateEventDedupeKey } from './events-store.js';
23
25
  import { dispatchDecision } from './runtime-dispatch.js';
24
- import type { AttentionHints, NotificationSignal } from './signal.js';
26
+ import type { AttentionHints, NotificationSignal, RoutingIntent } from './signal.js';
25
27
  import type { NotificationChannel, NotificationDeliveryResult } from './types.js';
26
28
 
27
29
  const log = getLogger('emit-signal');
@@ -46,6 +48,7 @@ function getBroadcaster(): NotificationBroadcaster {
46
48
  if (!broadcasterInstance) {
47
49
  const adapters = [
48
50
  new TelegramAdapter(),
51
+ new SmsAdapter(),
49
52
  ];
50
53
  if (registeredBroadcastFn) {
51
54
  adapters.unshift(new VellumAdapter(registeredBroadcastFn));
@@ -90,6 +93,7 @@ function getConnectedChannels(assistantId: string): NotificationChannel[] {
90
93
  channels.push(channel);
91
94
  break;
92
95
  case 'telegram':
96
+ case 'sms':
93
97
  // Only report binding-based channels as connected when there is
94
98
  // an active guardian binding for this assistant. Without a
95
99
  // binding, the destination resolver will fail to resolve a
@@ -125,6 +129,10 @@ export interface EmitSignalParams {
125
129
  attentionHints: AttentionHints;
126
130
  /** Arbitrary context payload passed to the decision engine. */
127
131
  contextPayload?: Record<string, unknown>;
132
+ /** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
133
+ routingIntent?: RoutingIntent;
134
+ /** Free-form hints from the source for the decision engine. */
135
+ routingHints?: Record<string, unknown>;
128
136
  /** Optional deduplication key. */
129
137
  dedupeKey?: string;
130
138
  /**
@@ -167,6 +175,8 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
167
175
  sourceEventName: params.sourceEventName,
168
176
  contextPayload: params.contextPayload ?? {},
169
177
  attentionHints: params.attentionHints,
178
+ routingIntent: params.routingIntent,
179
+ routingHints: params.routingHints,
170
180
  };
171
181
 
172
182
  try {
@@ -195,7 +205,29 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
195
205
 
196
206
  // Step 2: Evaluate the signal through the decision engine
197
207
  const connectedChannels = getConnectedChannels(assistantId);
198
- const decision = await evaluateSignal(signal, connectedChannels);
208
+ let decision = await evaluateSignal(signal, connectedChannels);
209
+
210
+ // Step 2.5: Enforce routing intent policy (fire-time guard)
211
+ const preEnforcementDecision = decision;
212
+ decision = enforceRoutingIntent(decision, signal.routingIntent, connectedChannels);
213
+
214
+ // Re-persist the decision if routing intent enforcement changed it,
215
+ // so the stored decision row matches what is actually dispatched.
216
+ if (decision !== preEnforcementDecision && decision.persistedDecisionId) {
217
+ try {
218
+ updateDecision(decision.persistedDecisionId, {
219
+ selectedChannels: decision.selectedChannels,
220
+ reasoningSummary: decision.reasoningSummary,
221
+ validationResults: {
222
+ dedupeKey: decision.dedupeKey,
223
+ channelCount: decision.selectedChannels.length,
224
+ hasCopy: Object.keys(decision.renderedCopy).length > 0,
225
+ },
226
+ });
227
+ } catch (err) {
228
+ log.warn({ err, signalId }, 'Failed to re-persist decision after routing intent enforcement');
229
+ }
230
+ }
199
231
 
200
232
  // Persist model-generated dedupeKey back to the event row so future
201
233
  // signals can deduplicate against it (the event was created with
@@ -12,6 +12,8 @@ export interface AttentionHints {
12
12
  visibleInSourceNow: boolean;
13
13
  }
14
14
 
15
+ export type RoutingIntent = 'single_channel' | 'multi_channel' | 'all_channels';
16
+
15
17
  export interface NotificationSignal {
16
18
  signalId: string;
17
19
  assistantId: string;
@@ -21,4 +23,8 @@ export interface NotificationSignal {
21
23
  sourceEventName: string; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
22
24
  contextPayload: Record<string, unknown>;
23
25
  attentionHints: AttentionHints;
26
+ /** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
27
+ routingIntent?: RoutingIntent;
28
+ /** Free-form hints from the source for the decision engine (e.g. preferred channels). */
29
+ routingHints?: Record<string, unknown>;
24
30
  }
@@ -54,6 +54,9 @@ export interface ChannelDeliveryPayload {
54
54
  /** Delivery audit record ID — passed through to the client for ack correlation. */
55
55
  deliveryId?: string;
56
56
  sourceEventName: string;
57
+ /** Originating assistant — used by channel adapters that need assistant-specific
58
+ * routing (e.g. SMS outbound number selection via the gateway). */
59
+ assistantId?: string;
57
60
  copy: RenderedChannelCopy;
58
61
  deepLinkTarget?: Record<string, unknown>;
59
62
  }
@@ -2,7 +2,7 @@ import { createConversation } from '../memory/conversation-store.js';
2
2
  import { GENERATING_TITLE, queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
3
3
  import { invalidateAssistantInferredItemsForConversation } from '../memory/task-memory-cleanup.js';
4
4
  import { runSequencesOnce } from '../sequence/engine.js';
5
- import { claimDueReminders, completeReminder, failReminder, setReminderConversationId } from '../tools/reminder/reminder-store.js';
5
+ import { claimDueReminders, completeReminder, failReminder, setReminderConversationId, type RoutingIntent } from '../tools/reminder/reminder-store.js';
6
6
  import { getLogger } from '../util/logger.js';
7
7
  import { runWatchersOnce, type WatcherEscalator,type WatcherNotifier } from '../watcher/engine.js';
8
8
  import { hasSetConstructs } from './recurrence-engine.js';
@@ -19,7 +19,13 @@ export type ScheduleMessageProcessor = (
19
19
  message: string,
20
20
  ) => Promise<unknown>;
21
21
 
22
- export type ReminderNotifier = (reminder: { id: string; label: string; message: string }) => void;
22
+ export type ReminderNotifier = (reminder: {
23
+ id: string;
24
+ label: string;
25
+ message: string;
26
+ routingIntent: RoutingIntent;
27
+ routingHints: Record<string, unknown>;
28
+ }) => void;
23
29
 
24
30
  export type ScheduleNotifier = (schedule: { id: string; name: string }) => void;
25
31
 
@@ -165,7 +171,13 @@ async function runScheduleOnce(
165
171
  } else {
166
172
  try {
167
173
  log.info({ reminderId: reminder.id, label: reminder.label }, 'Firing reminder notification');
168
- notifyReminder({ id: reminder.id, label: reminder.label, message: reminder.message });
174
+ notifyReminder({
175
+ id: reminder.id,
176
+ label: reminder.label,
177
+ message: reminder.message,
178
+ routingIntent: reminder.routingIntent,
179
+ routingHints: reminder.routingHints,
180
+ });
169
181
  completeReminder(reminder.id);
170
182
  } catch (err) {
171
183
  log.warn({ err, reminderId: reminder.id }, 'Reminder notification failed, reverting to pending');
@@ -15,6 +15,7 @@ import { PermissionDeniedError,ToolError } from '../util/errors.js';
15
15
  import { pathExists, safeStatSync } from '../util/fs.js';
16
16
  import { getLogger } from '../util/logger.js';
17
17
  import { resolveExecutionTarget } from './execution-target.js';
18
+ import { enforceGuardianOnlyPolicy } from './guardian-control-plane-policy.js';
18
19
  import { executeWithTimeout,safeTimeoutMs } from './execution-timeout.js';
19
20
  import { buildPolicyContext } from './policy-context.js';
20
21
  import { getAllTools,getTool } from './registry.js';
@@ -111,6 +112,34 @@ export class ToolExecutor {
111
112
  return { content: 'This tool is blocked by parental control settings.', isError: true };
112
113
  }
113
114
 
115
+ // Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
116
+ const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianActorRole);
117
+ if (guardianCheck.denied) {
118
+ log.warn({
119
+ toolName: name,
120
+ sessionId: context.sessionId,
121
+ conversationId: context.conversationId,
122
+ actorRole: context.guardianActorRole,
123
+ reason: 'guardian_only_policy',
124
+ }, 'Guardian-only policy blocked tool invocation');
125
+ const durationMs = Date.now() - startTime;
126
+ emitLifecycleEvent(context, {
127
+ type: 'permission_denied',
128
+ toolName: name,
129
+ executionTarget,
130
+ input,
131
+ workingDir: context.workingDir,
132
+ sessionId: context.sessionId,
133
+ conversationId: context.conversationId,
134
+ requestId: context.requestId,
135
+ riskLevel,
136
+ decision: 'deny',
137
+ reason: guardianCheck.reason!,
138
+ durationMs,
139
+ });
140
+ return { content: guardianCheck.reason!, isError: true };
141
+ }
142
+
114
143
  // Gate tools not active for the current turn
115
144
  if (context.allowedToolNames && !context.allowedToolNames.has(name)) {
116
145
  const msg = `Tool "${name}" is not currently active. Load the skill that provides this tool first.`;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Guardian control-plane policy \u2014 deterministic gate that prevents non-guardian
3
+ * and unverified_channel actors from invoking guardian verification endpoints
4
+ * conversationally via tools.
5
+ *
6
+ * Protected endpoints:
7
+ * /v1/integrations/guardian/challenge
8
+ * /v1/integrations/guardian/status
9
+ * /v1/integrations/guardian/outbound/start
10
+ * /v1/integrations/guardian/outbound/resend
11
+ * /v1/integrations/guardian/outbound/cancel
12
+ */
13
+
14
+ const GUARDIAN_ENDPOINT_PATHS = [
15
+ '/v1/integrations/guardian/challenge',
16
+ '/v1/integrations/guardian/status',
17
+ '/v1/integrations/guardian/outbound/start',
18
+ '/v1/integrations/guardian/outbound/resend',
19
+ '/v1/integrations/guardian/outbound/cancel',
20
+ ] as const;
21
+
22
+ /**
23
+ * Broad regex that catches any path targeting the guardian control-plane,
24
+ * even if the exact sub-path differs from the hardcoded list above.
25
+ * Anchored on a path separator so it won't match inside unrelated words.
26
+ */
27
+ const GUARDIAN_PATH_REGEX = /\/v1\/integrations\/guardian\//;
28
+
29
+ /** Tools whose `input.command` (string) may contain guardian endpoint paths. */
30
+ const COMMAND_TOOLS = new Set(['bash', 'host_bash']);
31
+
32
+ /** Tools whose `input.url` (string) may contain guardian endpoint paths. */
33
+ const URL_TOOLS = new Set(['network_request', 'web_fetch', 'browser_navigate']);
34
+
35
+ /**
36
+ * Normalize a string to defeat common URL obfuscation techniques before matching:
37
+ * - Decode percent-encoded characters (e.g. %2F → /)
38
+ * - Collapse consecutive slashes into a single slash (preserving protocol://)
39
+ * - Lowercase everything
40
+ */
41
+ function normalizeForMatching(value: string): string {
42
+ let normalized = value;
43
+ // Iteratively decode percent-encoding to handle double-encoding (%252F → %2F → /)
44
+ // Use per-sequence replacement instead of decodeURIComponent to avoid a single
45
+ // malformed sequence (e.g. %ZZ) preventing all other valid sequences from decoding.
46
+ let prev = '';
47
+ while (prev !== normalized) {
48
+ prev = normalized;
49
+ normalized = normalized.replace(/%[0-9a-fA-F]{2}/g, (match) => {
50
+ try {
51
+ return decodeURIComponent(match);
52
+ } catch {
53
+ return match;
54
+ }
55
+ });
56
+ }
57
+ // Collapse consecutive slashes (but preserve the double slash in protocol e.g. https://)
58
+ normalized = normalized.replace(/(?<!:)\/{2,}/g, '/');
59
+ return normalized.toLowerCase();
60
+ }
61
+
62
+ /**
63
+ * Check whether a string contains any of the guardian control-plane endpoint paths.
64
+ * Normalizes the input first to catch percent-encoding, double slashes, and case
65
+ * variations. Also matches a broad regex pattern to catch paths that target the
66
+ * guardian control-plane but aren't in the exact hardcoded list.
67
+ */
68
+ function containsGuardianEndpointPath(value: string): boolean {
69
+ const normalized = normalizeForMatching(value);
70
+ // Check exact hardcoded paths against the normalized string
71
+ for (const path of GUARDIAN_ENDPOINT_PATHS) {
72
+ if (normalized.includes(path)) return true;
73
+ }
74
+ // Broad pattern match to catch any /v1/integrations/guardian/... path
75
+ if (GUARDIAN_PATH_REGEX.test(normalized)) return true;
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Conservative fallback for shell tools: detects when a command contains the
81
+ * key fragments of a guardian control-plane path even if they are not contiguous
82
+ * (e.g. constructed via shell variable expansion like `base=/v1/integrations; curl "$base/guardian/status"`).
83
+ *
84
+ * Only applied to bash/host_bash — URL tools pass structured URLs that cannot
85
+ * be split by shell expansion.
86
+ */
87
+ function containsGuardianFragments(command: string): boolean {
88
+ const lower = command.toLowerCase();
89
+ return lower.includes('/v1/integrations') && lower.includes('guardian');
90
+ }
91
+
92
+ /**
93
+ * Pure function that determines whether a tool invocation targets a guardian
94
+ * control-plane endpoint based on the tool name and its input.
95
+ */
96
+ export function isGuardianControlPlaneInvocation(
97
+ toolName: string,
98
+ input: Record<string, unknown>,
99
+ ): boolean {
100
+ if (COMMAND_TOOLS.has(toolName)) {
101
+ const command = input.command;
102
+ if (typeof command === 'string') {
103
+ // Primary: exact/normalized path matching
104
+ if (containsGuardianEndpointPath(command)) return true;
105
+ // Fallback: detect shell-expanded construction of guardian paths
106
+ if (containsGuardianFragments(command)) return true;
107
+ }
108
+ }
109
+
110
+ if (URL_TOOLS.has(toolName)) {
111
+ const url = input.url;
112
+ if (typeof url === 'string' && containsGuardianEndpointPath(url)) {
113
+ return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ /**
121
+ * Enforce the guardian-only policy: if the invocation targets a guardian
122
+ * control-plane endpoint and the actor is not a guardian, deny.
123
+ */
124
+ export function enforceGuardianOnlyPolicy(
125
+ toolName: string,
126
+ input: Record<string, unknown>,
127
+ actorRole: string | undefined,
128
+ ): { denied: boolean; reason?: string } {
129
+ if (!isGuardianControlPlaneInvocation(toolName, input)) {
130
+ return { denied: false };
131
+ }
132
+
133
+ if (actorRole === 'guardian' || actorRole === undefined) {
134
+ return { denied: false };
135
+ }
136
+
137
+ return {
138
+ denied: true,
139
+ reason: 'Guardian verification control-plane actions are restricted to guardian users. This is a security restriction \u2014 please wait for the designated guardian to perform this action.',
140
+ };
141
+ }
@@ -136,6 +136,8 @@ export interface ToolContext {
136
136
  proxyApprovalCallback?: import('./network/script-proxy/types.js').ProxyApprovalCallback;
137
137
  /** Optional principal identifier propagated to sub-tool confirmation flows. */
138
138
  principal?: string;
139
+ /** Guardian actor role for the session — used by the guardian control-plane policy gate. */
140
+ guardianActorRole?: 'guardian' | 'non-guardian' | 'unverified_channel';
139
141
  }
140
142
 
141
143
  export interface DiffInfo {