@vellumai/assistant 0.3.4 → 0.3.5
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/Dockerfile +2 -0
- package/README.md +37 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +70 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -17
- package/src/__tests__/channel-approvals.test.ts +48 -1
- package/src/__tests__/channel-guardian.test.ts +74 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/handlers-twilio-config.test.ts +407 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +22 -11
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +21 -6
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/system-prompt.ts +24 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/daemon/handlers/config.ts +783 -9
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +108 -4
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/ride-shotgun-handler.ts +1 -1
- package/src/daemon/server.ts +6 -2
- package/src/daemon/session-agent-loop.ts +5 -1
- package/src/daemon/session-runtime-assembly.ts +55 -0
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +11 -1
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-init.ts +144 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/media-store.ts +759 -0
- package/src/memory/retriever.ts +6 -1
- package/src/memory/schema.ts +98 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +24 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +12 -4
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/http-server.ts +53 -27
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +67 -21
- package/src/runtime/run-orchestrator.ts +35 -2
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +35 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS messaging provider adapter.
|
|
3
|
+
*
|
|
4
|
+
* Enables proactive outbound SMS messaging via the gateway's /deliver/sms
|
|
5
|
+
* endpoint. Similar to the Telegram provider, SMS delivery is proxied through
|
|
6
|
+
* the gateway which owns the Twilio credentials and handles the Messages API.
|
|
7
|
+
*
|
|
8
|
+
* Twilio credentials (account_sid, auth_token) and a configured phone number
|
|
9
|
+
* are required for connectivity. The phone number is resolved from the config
|
|
10
|
+
* (sms.phoneNumber), env var (TWILIO_PHONE_NUMBER), or secure key fallback.
|
|
11
|
+
*
|
|
12
|
+
* The `token` parameter in MessagingProvider methods is unused for SMS
|
|
13
|
+
* because delivery is authenticated via the gateway's bearer token, not
|
|
14
|
+
* a per-user OAuth token.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { MessagingProvider } from '../../provider.js';
|
|
18
|
+
import type {
|
|
19
|
+
Conversation,
|
|
20
|
+
Message,
|
|
21
|
+
SearchResult,
|
|
22
|
+
SendResult,
|
|
23
|
+
ConnectionInfo,
|
|
24
|
+
ListOptions,
|
|
25
|
+
HistoryOptions,
|
|
26
|
+
SearchOptions,
|
|
27
|
+
SendOptions,
|
|
28
|
+
} from '../../provider-types.js';
|
|
29
|
+
import { getSecureKey } from '../../../security/secure-keys.js';
|
|
30
|
+
import { readHttpToken } from '../../../util/platform.js';
|
|
31
|
+
import { loadConfig } from '../../../config/loader.js';
|
|
32
|
+
import { getOrCreateConversation } from '../../../memory/conversation-key-store.js';
|
|
33
|
+
import * as externalConversationStore from '../../../memory/external-conversation-store.js';
|
|
34
|
+
import * as sms from './client.js';
|
|
35
|
+
|
|
36
|
+
/** Resolve the gateway base URL, preferring GATEWAY_INTERNAL_BASE_URL if set. */
|
|
37
|
+
function getGatewayUrl(): string {
|
|
38
|
+
if (process.env.GATEWAY_INTERNAL_BASE_URL) {
|
|
39
|
+
return process.env.GATEWAY_INTERNAL_BASE_URL.replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
const port = Number(process.env.GATEWAY_PORT) || 7830;
|
|
42
|
+
return `http://127.0.0.1:${port}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read the runtime HTTP bearer token used to authenticate with the gateway. */
|
|
46
|
+
function getBearerToken(): string {
|
|
47
|
+
const token = readHttpToken();
|
|
48
|
+
if (!token) {
|
|
49
|
+
throw new Error('No runtime HTTP bearer token available — is the daemon running?');
|
|
50
|
+
}
|
|
51
|
+
return token;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check whether Twilio credentials are stored. */
|
|
55
|
+
function hasTwilioCredentials(): boolean {
|
|
56
|
+
return (
|
|
57
|
+
!!getSecureKey('credential:twilio:account_sid') &&
|
|
58
|
+
!!getSecureKey('credential:twilio:auth_token')
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the configured SMS phone number.
|
|
64
|
+
* Priority: assistant-scoped phone number > TWILIO_PHONE_NUMBER env > config sms.phoneNumber > secure key fallback.
|
|
65
|
+
*/
|
|
66
|
+
function getPhoneNumber(assistantId?: string): string | undefined {
|
|
67
|
+
// Check assistant-scoped phone number first
|
|
68
|
+
if (assistantId) {
|
|
69
|
+
try {
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const assistantPhone = config.sms?.assistantPhoneNumbers?.[assistantId];
|
|
72
|
+
if (assistantPhone) return assistantPhone;
|
|
73
|
+
} catch {
|
|
74
|
+
// Config may not be available yet during early startup
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fromEnv = process.env.TWILIO_PHONE_NUMBER;
|
|
79
|
+
if (fromEnv) return fromEnv;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const config = loadConfig();
|
|
83
|
+
if (config.sms?.phoneNumber) return config.sms.phoneNumber;
|
|
84
|
+
} catch {
|
|
85
|
+
// Config may not be available yet during early startup
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return getSecureKey('credential:twilio:phone_number') || undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hasAnyAssistantPhoneNumber(): boolean {
|
|
92
|
+
try {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
return Object.keys(config.sms?.assistantPhoneNumbers ?? {}).length > 0;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const smsMessagingProvider: MessagingProvider = {
|
|
101
|
+
id: 'sms',
|
|
102
|
+
displayName: 'SMS',
|
|
103
|
+
credentialService: 'twilio',
|
|
104
|
+
capabilities: new Set(['send']),
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* SMS is connected when Twilio credentials are stored AND a phone number
|
|
108
|
+
* is configured. Without a phone number the gateway cannot determine
|
|
109
|
+
* the `from` for outbound messages.
|
|
110
|
+
*/
|
|
111
|
+
isConnected(): boolean {
|
|
112
|
+
return hasTwilioCredentials() && (!!getPhoneNumber() || hasAnyAssistantPhoneNumber());
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async testConnection(_token: string): Promise<ConnectionInfo> {
|
|
116
|
+
if (!hasTwilioCredentials()) {
|
|
117
|
+
return {
|
|
118
|
+
connected: false,
|
|
119
|
+
user: 'unknown',
|
|
120
|
+
platform: 'sms',
|
|
121
|
+
metadata: { error: 'No Twilio credentials found. Run the twilio-setup skill.' },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const phoneNumber = getPhoneNumber();
|
|
126
|
+
if (!phoneNumber && !hasAnyAssistantPhoneNumber()) {
|
|
127
|
+
return {
|
|
128
|
+
connected: false,
|
|
129
|
+
user: 'unknown',
|
|
130
|
+
platform: 'sms',
|
|
131
|
+
metadata: { error: 'No phone number configured. Run the twilio-setup skill to assign a number.' },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const accountSid = getSecureKey('credential:twilio:account_sid')!;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
connected: true,
|
|
139
|
+
user: phoneNumber ?? 'assistant-scoped numbers configured',
|
|
140
|
+
platform: 'sms',
|
|
141
|
+
metadata: {
|
|
142
|
+
accountSid: accountSid.slice(0, 6) + '...',
|
|
143
|
+
...(phoneNumber ? { phoneNumber } : {}),
|
|
144
|
+
hasAssistantScopedPhoneNumbers: hasAnyAssistantPhoneNumber(),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async sendMessage(_token: string, conversationId: string, text: string, options?: SendOptions): Promise<SendResult> {
|
|
150
|
+
const gatewayUrl = getGatewayUrl();
|
|
151
|
+
const bearerToken = getBearerToken();
|
|
152
|
+
const assistantId = options?.assistantId;
|
|
153
|
+
|
|
154
|
+
const sendResult = await sms.sendMessage(gatewayUrl, bearerToken, conversationId, text, assistantId);
|
|
155
|
+
|
|
156
|
+
// Upsert external conversation binding so the conversation key mapping
|
|
157
|
+
// exists for the next inbound SMS from this number.
|
|
158
|
+
try {
|
|
159
|
+
const sourceChannel = 'sms';
|
|
160
|
+
const conversationKey = assistantId && assistantId !== 'self'
|
|
161
|
+
? `asst:${assistantId}:${sourceChannel}:${conversationId}`
|
|
162
|
+
: `${sourceChannel}:${conversationId}`;
|
|
163
|
+
const { conversationId: internalId } = getOrCreateConversation(conversationKey);
|
|
164
|
+
// external_conversation_bindings is assistant-agnostic (unique by
|
|
165
|
+
// sourceChannel + externalChatId). Restrict proactive writes to self so
|
|
166
|
+
// multi-assistant sends cannot clobber each other's binding metadata.
|
|
167
|
+
if (!assistantId || assistantId === 'self') {
|
|
168
|
+
externalConversationStore.upsertOutboundBinding({
|
|
169
|
+
conversationId: internalId,
|
|
170
|
+
sourceChannel,
|
|
171
|
+
externalChatId: conversationId,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Best-effort — don't fail the send if binding upsert fails
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Use the Twilio message SID as the send result ID when available,
|
|
179
|
+
// falling back to a timestamp-based ID for older gateway versions.
|
|
180
|
+
const id = sendResult.messageSid || `sms-${Date.now()}`;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
timestamp: Date.now(),
|
|
185
|
+
conversationId,
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// SMS does not support listing conversations. The assistant can only
|
|
190
|
+
// send to known phone numbers (conversation IDs).
|
|
191
|
+
async listConversations(_token: string, _options?: ListOptions): Promise<Conversation[]> {
|
|
192
|
+
return [];
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
// SMS does not provide message history retrieval via the gateway.
|
|
196
|
+
async getHistory(_token: string, _conversationId: string, _options?: HistoryOptions): Promise<Message[]> {
|
|
197
|
+
return [];
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// SMS does not support message search.
|
|
201
|
+
async search(_token: string, _query: string, _options?: SearchOptions): Promise<SearchResult> {
|
|
202
|
+
return { total: 0, messages: [], hasMore: false };
|
|
203
|
+
},
|
|
204
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level SMS operations.
|
|
3
|
+
*
|
|
4
|
+
* Outbound message delivery routes through the gateway's /deliver/sms
|
|
5
|
+
* endpoint, which handles Twilio credential management and the Messages API.
|
|
6
|
+
* The gateway resolves the `from` number using the optional assistantId or
|
|
7
|
+
* its default Twilio phone number configuration.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DELIVERY_TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
export class SmsApiError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly status: number,
|
|
15
|
+
message: string,
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'SmsApiError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Payload accepted by the gateway's /deliver/sms endpoint. */
|
|
23
|
+
interface DeliverPayload {
|
|
24
|
+
to: string;
|
|
25
|
+
text: string;
|
|
26
|
+
assistantId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result returned by sendMessage with Twilio acceptance details. */
|
|
30
|
+
export interface SmsSendResult {
|
|
31
|
+
messageSid?: string;
|
|
32
|
+
status?: string;
|
|
33
|
+
errorCode?: string | null;
|
|
34
|
+
errorMessage?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Send an SMS message via the gateway's /deliver/sms endpoint.
|
|
39
|
+
*
|
|
40
|
+
* Returns Twilio acceptance details propagated from the gateway.
|
|
41
|
+
* "Accepted" means Twilio received it for delivery -- it has NOT yet
|
|
42
|
+
* been confirmed as delivered to the handset.
|
|
43
|
+
*/
|
|
44
|
+
export async function sendMessage(
|
|
45
|
+
gatewayUrl: string,
|
|
46
|
+
bearerToken: string,
|
|
47
|
+
to: string,
|
|
48
|
+
text: string,
|
|
49
|
+
assistantId?: string,
|
|
50
|
+
): Promise<SmsSendResult> {
|
|
51
|
+
const payload: DeliverPayload = { to, text };
|
|
52
|
+
if (assistantId) {
|
|
53
|
+
payload.assistantId = assistantId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const url = `${gatewayUrl}/deliver/sms`;
|
|
57
|
+
const resp = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!resp.ok) {
|
|
68
|
+
const body = await resp.text().catch(() => '<unreadable>');
|
|
69
|
+
throw new SmsApiError(
|
|
70
|
+
resp.status,
|
|
71
|
+
`Gateway /deliver/sms failed (${resp.status}): ${body}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const data = (await resp.json()) as {
|
|
77
|
+
ok?: boolean;
|
|
78
|
+
messageSid?: string;
|
|
79
|
+
status?: string;
|
|
80
|
+
errorCode?: string | null;
|
|
81
|
+
errorMessage?: string | null;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
messageSid: data.messageSid,
|
|
85
|
+
status: data.status,
|
|
86
|
+
errorCode: data.errorCode,
|
|
87
|
+
errorMessage: data.errorMessage,
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
// Older gateway versions may not return JSON with Twilio details
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -12,6 +12,7 @@ import { looksLikeHostPortShorthand, looksLikePathOnlyInput } from '../tools/net
|
|
|
12
12
|
import { normalizeFilePath, isSkillSourcePath } from '../skills/path-classifier.js';
|
|
13
13
|
import { isWorkspaceScopedInvocation } from './workspace-policy.js';
|
|
14
14
|
import { buildShellCommandCandidates, buildShellAllowlistOptions, type ParsedCommand } from './shell-identity.js';
|
|
15
|
+
import type { ManifestOverride } from '../tools/execution-target.js';
|
|
15
16
|
|
|
16
17
|
// Ensures the legacy mode deprecation warning fires at most once per process.
|
|
17
18
|
let _legacyDeprecationWarned = false;
|
|
@@ -244,7 +245,7 @@ async function buildCommandCandidates(toolName: string, input: Record<string, un
|
|
|
244
245
|
return [...new Set(candidates)];
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
export async function classifyRisk(toolName: string, input: Record<string, unknown>, workingDir?: string, preParsed?: ParsedCommand): Promise<RiskLevel> {
|
|
248
|
+
export async function classifyRisk(toolName: string, input: Record<string, unknown>, workingDir?: string, preParsed?: ParsedCommand, manifestOverride?: ManifestOverride): Promise<RiskLevel> {
|
|
248
249
|
if (toolName === 'file_read') return RiskLevel.Low;
|
|
249
250
|
if (toolName === 'file_write' || toolName === 'file_edit') {
|
|
250
251
|
const filePath = getStringField(input, 'path', 'file_path');
|
|
@@ -342,6 +343,13 @@ export async function classifyRisk(toolName: string, input: Record<string, unkno
|
|
|
342
343
|
const tool = getTool(toolName);
|
|
343
344
|
if (tool) return tool.defaultRiskLevel;
|
|
344
345
|
|
|
346
|
+
// Use manifest metadata for unregistered skill tools so the Permission
|
|
347
|
+
// Simulator shows accurate risk levels instead of defaulting to Medium.
|
|
348
|
+
if (manifestOverride) {
|
|
349
|
+
const riskMap: Record<string, RiskLevel> = { low: RiskLevel.Low, medium: RiskLevel.Medium, high: RiskLevel.High };
|
|
350
|
+
return riskMap[manifestOverride.risk] ?? RiskLevel.Medium;
|
|
351
|
+
}
|
|
352
|
+
|
|
345
353
|
// Unknown tool → Medium
|
|
346
354
|
return RiskLevel.Medium;
|
|
347
355
|
}
|
|
@@ -351,6 +359,7 @@ export async function check(
|
|
|
351
359
|
input: Record<string, unknown>,
|
|
352
360
|
workingDir: string,
|
|
353
361
|
policyContext?: PolicyContext,
|
|
362
|
+
manifestOverride?: ManifestOverride,
|
|
354
363
|
): Promise<PermissionCheckResult> {
|
|
355
364
|
// For shell tools, parse once and share the result to avoid duplicate tree-sitter work.
|
|
356
365
|
let shellParsed: ParsedCommand | undefined;
|
|
@@ -361,7 +370,7 @@ export async function check(
|
|
|
361
370
|
}
|
|
362
371
|
}
|
|
363
372
|
|
|
364
|
-
const risk = await classifyRisk(toolName, input, workingDir, shellParsed);
|
|
373
|
+
const risk = await classifyRisk(toolName, input, workingDir, shellParsed, manifestOverride);
|
|
365
374
|
|
|
366
375
|
// Build command string candidates for rule matching
|
|
367
376
|
const commandCandidates = await buildCommandCandidates(toolName, input, workingDir, shellParsed);
|
|
@@ -406,11 +415,16 @@ export async function check(
|
|
|
406
415
|
// Third-party skill-origin tools default to prompting when no trust rule
|
|
407
416
|
// matches, regardless of risk level. Bundled skill tools are first-party
|
|
408
417
|
// and trusted, so they fall through to the normal risk-based policy.
|
|
418
|
+
// When manifestOverride is present, the tool comes from a skill manifest
|
|
419
|
+
// but isn't registered — treat it as a third-party skill tool.
|
|
409
420
|
if (!matchedRule) {
|
|
410
421
|
const tool = getTool(toolName);
|
|
411
422
|
if (tool?.origin === 'skill' && !tool.ownerSkillBundled) {
|
|
412
423
|
return { decision: 'prompt', reason: 'Skill tool: requires approval by default' };
|
|
413
424
|
}
|
|
425
|
+
if (!tool && manifestOverride) {
|
|
426
|
+
return { decision: 'prompt', reason: 'Skill tool: requires approval by default' };
|
|
427
|
+
}
|
|
414
428
|
}
|
|
415
429
|
|
|
416
430
|
// In strict mode, every tool without an explicit matching rule must be
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered approval message composition system.
|
|
3
|
+
*
|
|
4
|
+
* Generates approval prompt text through a priority chain:
|
|
5
|
+
* 1. Assistant preface (macOS parity — reuse existing assistant text)
|
|
6
|
+
* 2. Deterministic fallback templates (natural, scenario-specific messages)
|
|
7
|
+
*
|
|
8
|
+
* A provider-backed generation layer can be inserted between 1 and 2 later.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type ApprovalMessageScenario =
|
|
16
|
+
| 'standard_prompt'
|
|
17
|
+
| 'guardian_prompt'
|
|
18
|
+
| 'reminder_prompt'
|
|
19
|
+
| 'guardian_delivery_failed'
|
|
20
|
+
| 'guardian_request_forwarded'
|
|
21
|
+
| 'guardian_disambiguation'
|
|
22
|
+
| 'guardian_identity_mismatch'
|
|
23
|
+
| 'request_pending_guardian'
|
|
24
|
+
| 'guardian_decision_outcome'
|
|
25
|
+
| 'guardian_expired_requester'
|
|
26
|
+
| 'guardian_expired_guardian'
|
|
27
|
+
| 'guardian_verify_success'
|
|
28
|
+
| 'guardian_verify_failed'
|
|
29
|
+
| 'guardian_verify_challenge_setup'
|
|
30
|
+
| 'guardian_verify_status_bound'
|
|
31
|
+
| 'guardian_verify_status_unbound'
|
|
32
|
+
| 'guardian_deny_no_identity'
|
|
33
|
+
| 'guardian_deny_no_binding';
|
|
34
|
+
|
|
35
|
+
export interface ApprovalMessageContext {
|
|
36
|
+
scenario: ApprovalMessageScenario;
|
|
37
|
+
channel?: string;
|
|
38
|
+
toolName?: string;
|
|
39
|
+
requesterIdentifier?: string;
|
|
40
|
+
guardianIdentifier?: string;
|
|
41
|
+
pendingCount?: number;
|
|
42
|
+
decision?: 'approved' | 'denied';
|
|
43
|
+
richUi?: boolean;
|
|
44
|
+
/** Pre-existing assistant text to reuse (macOS parity). */
|
|
45
|
+
assistantPreface?: string;
|
|
46
|
+
verifyCommand?: string;
|
|
47
|
+
ttlSeconds?: number;
|
|
48
|
+
failureReason?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Public API
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compose an approval message using layered source selection:
|
|
57
|
+
* 1. If an assistant preface is provided and non-empty, return it directly.
|
|
58
|
+
* 2. Otherwise fall back to a deterministic scenario-specific template.
|
|
59
|
+
*/
|
|
60
|
+
export function composeApprovalMessage(context: ApprovalMessageContext): string {
|
|
61
|
+
if (context.assistantPreface && context.assistantPreface.trim().length > 0) {
|
|
62
|
+
return context.assistantPreface;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return getFallbackMessage(context);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Deterministic fallback templates
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return a scenario-specific deterministic fallback message.
|
|
74
|
+
*
|
|
75
|
+
* Each template is slightly more conversational than the old hard-coded
|
|
76
|
+
* strings while preserving all required semantic content (tool name,
|
|
77
|
+
* who must approve, next action, etc.).
|
|
78
|
+
*/
|
|
79
|
+
export function getFallbackMessage(context: ApprovalMessageContext): string {
|
|
80
|
+
switch (context.scenario) {
|
|
81
|
+
case 'standard_prompt':
|
|
82
|
+
return `I'd like to use the tool "${context.toolName ?? 'unknown'}". Would you like to allow this?`;
|
|
83
|
+
|
|
84
|
+
case 'guardian_prompt':
|
|
85
|
+
return `${context.requesterIdentifier ?? 'A user'} is requesting to use "${context.toolName ?? 'unknown'}". Please approve or deny this request.`;
|
|
86
|
+
|
|
87
|
+
case 'reminder_prompt':
|
|
88
|
+
return "I'm still waiting for your decision on the pending approval request.";
|
|
89
|
+
|
|
90
|
+
case 'guardian_delivery_failed':
|
|
91
|
+
return context.toolName
|
|
92
|
+
? `Your request to run "${context.toolName}" could not be sent to the guardian for approval. The request has been denied for safety.`
|
|
93
|
+
: "I wasn't able to reach the guardian to request approval. The request has been denied for safety.";
|
|
94
|
+
|
|
95
|
+
case 'guardian_request_forwarded':
|
|
96
|
+
return `Your request to use "${context.toolName ?? 'unknown'}" has been forwarded to the guardian for approval. I'll let you know once they decide.`;
|
|
97
|
+
|
|
98
|
+
case 'guardian_disambiguation':
|
|
99
|
+
return `There are ${context.pendingCount ?? 'multiple'} pending approval requests. Please use the approval buttons to specify which request you're responding to.`;
|
|
100
|
+
|
|
101
|
+
case 'guardian_identity_mismatch':
|
|
102
|
+
return 'This approval request can only be handled by the designated guardian.';
|
|
103
|
+
|
|
104
|
+
case 'request_pending_guardian':
|
|
105
|
+
return 'Your request is pending guardian approval. Please wait for the guardian to respond.';
|
|
106
|
+
|
|
107
|
+
case 'guardian_decision_outcome':
|
|
108
|
+
return `The guardian has ${context.decision ?? 'decided on'} your request to use "${context.toolName ?? 'unknown'}".`;
|
|
109
|
+
|
|
110
|
+
case 'guardian_expired_requester':
|
|
111
|
+
return `The approval request for "${context.toolName ?? 'unknown'}" has expired without a guardian response. The request has been denied.`;
|
|
112
|
+
|
|
113
|
+
case 'guardian_expired_guardian':
|
|
114
|
+
return `The approval request from ${context.requesterIdentifier ?? 'the requester'} for "${context.toolName ?? 'unknown'}" has expired.`;
|
|
115
|
+
|
|
116
|
+
case 'guardian_verify_success':
|
|
117
|
+
return 'Guardian verification successful! You are now set as the guardian for this channel.';
|
|
118
|
+
|
|
119
|
+
case 'guardian_verify_failed':
|
|
120
|
+
return `Verification failed. ${context.failureReason ?? 'Please try again.'}`;
|
|
121
|
+
|
|
122
|
+
case 'guardian_verify_challenge_setup':
|
|
123
|
+
return `To complete guardian verification, send ${context.verifyCommand ?? 'the verification command'} within ${context.ttlSeconds ?? 60} seconds.`;
|
|
124
|
+
|
|
125
|
+
case 'guardian_verify_status_bound':
|
|
126
|
+
return 'A guardian is currently active for this channel.';
|
|
127
|
+
|
|
128
|
+
case 'guardian_verify_status_unbound':
|
|
129
|
+
return 'No guardian is currently configured for this channel.';
|
|
130
|
+
|
|
131
|
+
case 'guardian_deny_no_identity':
|
|
132
|
+
return 'This action requires approval, but your identity could not be verified. The request has been denied for safety.';
|
|
133
|
+
|
|
134
|
+
case 'guardian_deny_no_binding':
|
|
135
|
+
return 'This action requires guardian approval, but no guardian has been configured for this channel. The request has been denied for safety.';
|
|
136
|
+
|
|
137
|
+
default: {
|
|
138
|
+
// Exhaustive check — TypeScript will flag if a scenario is missing.
|
|
139
|
+
const _exhaustive: never = context.scenario;
|
|
140
|
+
return `Approval required. ${String(_exhaustive)}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
ApprovalUIMetadata,
|
|
22
22
|
ApprovalDecisionResult,
|
|
23
23
|
} from './channel-approval-types.js';
|
|
24
|
+
import { composeApprovalMessage } from './approval-message-composer.js';
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// 1. Detect pending confirmations and build prompt
|
|
@@ -47,13 +48,17 @@ export function getChannelApprovalPrompt(
|
|
|
47
48
|
* Internal helper: turn a PendingRunInfo into a ChannelApprovalPrompt.
|
|
48
49
|
*/
|
|
49
50
|
function buildPromptFromRunInfo(info: PendingRunInfo): ChannelApprovalPrompt {
|
|
50
|
-
const promptText =
|
|
51
|
+
const promptText = composeApprovalMessage({
|
|
52
|
+
scenario: 'standard_prompt',
|
|
53
|
+
toolName: info.toolName,
|
|
54
|
+
});
|
|
51
55
|
|
|
52
56
|
// Hide "approve always" when persistent trust rules are disallowed for this invocation.
|
|
53
57
|
const actions = info.persistentDecisionsAllowed === false
|
|
54
58
|
? DEFAULT_APPROVAL_ACTIONS.filter((a) => a.id !== 'approve_always')
|
|
55
59
|
: [...DEFAULT_APPROVAL_ACTIONS];
|
|
56
60
|
|
|
61
|
+
// Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
|
|
57
62
|
const plainTextFallback = info.persistentDecisionsAllowed === false
|
|
58
63
|
? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
|
|
59
64
|
: `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
|
|
@@ -166,8 +171,11 @@ export function buildGuardianApprovalPrompt(
|
|
|
166
171
|
info: PendingRunInfo,
|
|
167
172
|
requesterIdentifier: string,
|
|
168
173
|
): ChannelApprovalPrompt {
|
|
169
|
-
const promptText =
|
|
170
|
-
|
|
174
|
+
const promptText = composeApprovalMessage({
|
|
175
|
+
scenario: 'guardian_prompt',
|
|
176
|
+
toolName: info.toolName,
|
|
177
|
+
requesterIdentifier,
|
|
178
|
+
});
|
|
171
179
|
|
|
172
180
|
// Guardian approvals are always one-time decisions — "approve always"
|
|
173
181
|
// doesn't make sense when the guardian is approving on behalf of someone else.
|
|
@@ -211,7 +219,7 @@ export function channelSupportsRichApprovalUI(channel: string): boolean {
|
|
|
211
219
|
export function buildReminderPrompt(
|
|
212
220
|
pendingPrompt: ChannelApprovalPrompt,
|
|
213
221
|
): ChannelApprovalPrompt {
|
|
214
|
-
const reminderPrefix =
|
|
222
|
+
const reminderPrefix = composeApprovalMessage({ scenario: 'reminder_prompt' });
|
|
215
223
|
return {
|
|
216
224
|
promptText: `${reminderPrefix}\n\n${pendingPrompt.promptText}`,
|
|
217
225
|
actions: pendingPrompt.actions,
|