@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.
- package/ARCHITECTURE.md +17 -3
- package/README.md +2 -0
- package/docs/architecture/scheduling.md +81 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
- package/src/__tests__/channel-policy.test.ts +19 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +584 -0
- package/src/__tests__/intent-routing.test.ts +22 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/notification-routing-intent.test.ts +186 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +181 -0
- package/src/__tests__/recording-intent-handler.test.ts +593 -73
- package/src/__tests__/recording-intent.test.ts +739 -343
- package/src/__tests__/recording-state-machine.test.ts +1109 -0
- package/src/__tests__/reminder-store.test.ts +20 -18
- package/src/__tests__/reminder.test.ts +2 -1
- package/src/channels/config.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
- package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
- package/src/config/system-prompt.ts +5 -0
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +136 -62
- package/src/daemon/ipc-contract/computer-use.ts +23 -3
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-contract/shared.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/recording-executor.ts +180 -0
- package/src/daemon/recording-intent-fallback.ts +132 -0
- package/src/daemon/recording-intent.ts +306 -15
- package/src/daemon/session-tool-setup.ts +4 -0
- package/src/notifications/README.md +69 -1
- package/src/notifications/adapters/sms.ts +80 -0
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +3 -3
- package/src/notifications/decision-engine.ts +70 -1
- package/src/notifications/decisions-store.ts +24 -0
- package/src/notifications/destination-resolver.ts +2 -1
- package/src/notifications/emit-signal.ts +35 -3
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/types.ts +3 -0
- package/src/schedule/scheduler.ts +15 -3
- package/src/tools/executor.ts +29 -0
- package/src/tools/guardian-control-plane-policy.ts +141 -0
- 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
|
-
|
|
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: {
|
|
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({
|
|
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');
|
package/src/tools/executor.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/types.ts
CHANGED
|
@@ -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 {
|