@vellumai/assistant 0.3.18 → 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.
- package/ARCHITECTURE.md +4 -0
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-dispatch.test.ts +61 -1
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +1 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/calls/call-controller.ts +27 -6
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +42 -3
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/session-process.ts +12 -0
- package/src/memory/db-init.ts +9 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/guardian-action-store.ts +8 -0
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +30 -0
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/routes/guardian-approval-interception.ts +116 -0
- package/src/runtime/routes/inbound-message-handler.ts +94 -27
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
type GuardianApprovalRequest,
|
|
12
12
|
updateApprovalDecision,
|
|
13
13
|
} from '../../memory/channel-guardian-store.js';
|
|
14
|
+
import { createScopedApprovalGrant } from '../../memory/scoped-approval-grants.js';
|
|
14
15
|
import { emitNotificationSignal } from '../../notifications/emit-signal.js';
|
|
16
|
+
import { computeToolApprovalDigest } from '../../security/tool-approval-digest.js';
|
|
15
17
|
import { getLogger } from '../../util/logger.js';
|
|
16
18
|
import { runApprovalConversationTurn } from '../approval-conversation-turn.js';
|
|
17
19
|
import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
getApprovalInfoByConversation,
|
|
24
26
|
getChannelApprovalPrompt,
|
|
25
27
|
handleChannelDecision,
|
|
28
|
+
type PendingApprovalInfo,
|
|
26
29
|
} from '../channel-approvals.js';
|
|
27
30
|
import { deliverChannelReply } from '../gateway-client.js';
|
|
28
31
|
import type {
|
|
@@ -46,6 +49,68 @@ import {
|
|
|
46
49
|
|
|
47
50
|
const log = getLogger('runtime-http');
|
|
48
51
|
|
|
52
|
+
/** TTL for scoped approval grants minted on guardian approve_once decisions. */
|
|
53
|
+
export const GRANT_TTL_MS = 5 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Scoped grant minting on guardian tool-approval decisions
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
|
|
61
|
+
* request. Only mints when the approval info contains a tool invocation with
|
|
62
|
+
* input (so we can compute the input digest). Informational ASK_GUARDIAN
|
|
63
|
+
* requests that lack tool input are skipped.
|
|
64
|
+
*
|
|
65
|
+
* Fails silently on error — grant minting is best-effort and must never block
|
|
66
|
+
* the approval flow.
|
|
67
|
+
*/
|
|
68
|
+
function tryMintToolApprovalGrant(params: {
|
|
69
|
+
approvalInfo: PendingApprovalInfo;
|
|
70
|
+
approval: GuardianApprovalRequest;
|
|
71
|
+
decisionChannel: ChannelId;
|
|
72
|
+
guardianExternalUserId: string;
|
|
73
|
+
}): void {
|
|
74
|
+
const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
|
|
75
|
+
|
|
76
|
+
// Only mint for requests that carry a tool name — the presence of toolName
|
|
77
|
+
// distinguishes tool-approval requests from informational ones.
|
|
78
|
+
// computeToolApprovalDigest can deterministically hash {} so zero-argument
|
|
79
|
+
// tool invocations must still receive a grant.
|
|
80
|
+
if (!approvalInfo.toolName) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
|
|
86
|
+
|
|
87
|
+
createScopedApprovalGrant({
|
|
88
|
+
assistantId: approval.assistantId,
|
|
89
|
+
scopeMode: 'tool_signature',
|
|
90
|
+
toolName: approvalInfo.toolName,
|
|
91
|
+
inputDigest,
|
|
92
|
+
requestChannel: approval.channel,
|
|
93
|
+
decisionChannel,
|
|
94
|
+
executionChannel: null,
|
|
95
|
+
conversationId: approval.conversationId,
|
|
96
|
+
callSessionId: null,
|
|
97
|
+
guardianExternalUserId,
|
|
98
|
+
requesterExternalUserId: approval.requesterExternalUserId,
|
|
99
|
+
expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
log.info(
|
|
103
|
+
{ toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
104
|
+
'Minted scoped approval grant for guardian tool-approval decision',
|
|
105
|
+
);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.error(
|
|
108
|
+
{ err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
109
|
+
'Failed to mint scoped approval grant (non-fatal)',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
49
114
|
export interface ApprovalInterceptionParams {
|
|
50
115
|
conversationId: string;
|
|
51
116
|
callbackData?: string;
|
|
@@ -207,6 +272,13 @@ export async function handleApprovalInterception(
|
|
|
207
272
|
return accessResult;
|
|
208
273
|
}
|
|
209
274
|
|
|
275
|
+
// Capture pending approval info before handleChannelDecision resolves
|
|
276
|
+
// (and removes) the pending interaction. Needed for grant minting.
|
|
277
|
+
const cbApprovalInfo = getApprovalInfoByConversation(guardianApproval.conversationId);
|
|
278
|
+
const cbMatchedInfo = callbackDecision.requestId
|
|
279
|
+
? cbApprovalInfo.find(a => a.requestId === callbackDecision!.requestId)
|
|
280
|
+
: cbApprovalInfo[0];
|
|
281
|
+
|
|
210
282
|
// Apply the decision to the underlying session using the requester's
|
|
211
283
|
// conversation context
|
|
212
284
|
const result = handleChannelDecision(
|
|
@@ -224,6 +296,16 @@ export async function handleApprovalInterception(
|
|
|
224
296
|
decidedByExternalUserId: senderExternalUserId,
|
|
225
297
|
});
|
|
226
298
|
|
|
299
|
+
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
300
|
+
if (callbackDecision.action !== 'reject' && cbMatchedInfo) {
|
|
301
|
+
tryMintToolApprovalGrant({
|
|
302
|
+
approvalInfo: cbMatchedInfo,
|
|
303
|
+
approval: guardianApproval,
|
|
304
|
+
decisionChannel: sourceChannel,
|
|
305
|
+
guardianExternalUserId: senderExternalUserId,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
227
309
|
// Notify the requester's chat about the outcome with the tool name
|
|
228
310
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
229
311
|
scenario: 'guardian_decision_outcome',
|
|
@@ -346,6 +428,13 @@ export async function handleApprovalInterception(
|
|
|
346
428
|
...(engineResult.targetRequestId ? { requestId: engineResult.targetRequestId } : {}),
|
|
347
429
|
};
|
|
348
430
|
|
|
431
|
+
// Capture pending approval info before handleChannelDecision resolves
|
|
432
|
+
// (and removes) the pending interaction. Needed for grant minting.
|
|
433
|
+
const engineApprovalInfo = getApprovalInfoByConversation(targetApproval.conversationId);
|
|
434
|
+
const engineMatchedInfo = engineDecision.requestId
|
|
435
|
+
? engineApprovalInfo.find(a => a.requestId === engineDecision.requestId)
|
|
436
|
+
: engineApprovalInfo[0];
|
|
437
|
+
|
|
349
438
|
const result = handleChannelDecision(
|
|
350
439
|
targetApproval.conversationId,
|
|
351
440
|
engineDecision,
|
|
@@ -361,6 +450,16 @@ export async function handleApprovalInterception(
|
|
|
361
450
|
decidedByExternalUserId: senderExternalUserId,
|
|
362
451
|
});
|
|
363
452
|
|
|
453
|
+
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
454
|
+
if (decisionAction !== 'reject' && engineMatchedInfo) {
|
|
455
|
+
tryMintToolApprovalGrant({
|
|
456
|
+
approvalInfo: engineMatchedInfo,
|
|
457
|
+
approval: targetApproval,
|
|
458
|
+
decisionChannel: sourceChannel,
|
|
459
|
+
guardianExternalUserId: senderExternalUserId,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
364
463
|
// Notify the requester's chat about the outcome
|
|
365
464
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
366
465
|
scenario: 'guardian_decision_outcome',
|
|
@@ -492,6 +591,13 @@ export async function handleApprovalInterception(
|
|
|
492
591
|
return accessResult;
|
|
493
592
|
}
|
|
494
593
|
|
|
594
|
+
// Capture pending approval info before handleChannelDecision resolves
|
|
595
|
+
// (and removes) the pending interaction. Needed for grant minting.
|
|
596
|
+
const legacyApprovalInfo = getApprovalInfoByConversation(targetLegacyApproval.conversationId);
|
|
597
|
+
const legacyMatchedInfo = legacyGuardianDecision.requestId
|
|
598
|
+
? legacyApprovalInfo.find(a => a.requestId === legacyGuardianDecision.requestId)
|
|
599
|
+
: legacyApprovalInfo[0];
|
|
600
|
+
|
|
495
601
|
const result = handleChannelDecision(
|
|
496
602
|
targetLegacyApproval.conversationId,
|
|
497
603
|
legacyGuardianDecision,
|
|
@@ -504,6 +610,16 @@ export async function handleApprovalInterception(
|
|
|
504
610
|
decidedByExternalUserId: senderExternalUserId,
|
|
505
611
|
});
|
|
506
612
|
|
|
613
|
+
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
614
|
+
if (legacyGuardianDecision.action !== 'reject' && legacyMatchedInfo) {
|
|
615
|
+
tryMintToolApprovalGrant({
|
|
616
|
+
approvalInfo: legacyMatchedInfo,
|
|
617
|
+
approval: targetLegacyApproval,
|
|
618
|
+
decisionChannel: sourceChannel,
|
|
619
|
+
guardianExternalUserId: senderExternalUserId,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
507
623
|
// Notify the requester's chat about the outcome
|
|
508
624
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
509
625
|
scenario: 'guardian_decision_outcome',
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
} from './channel-route-shared.js';
|
|
79
79
|
import { handleApprovalInterception } from './guardian-approval-interception.js';
|
|
80
80
|
import { deliverGeneratedApprovalPrompt } from './guardian-approval-prompt.js';
|
|
81
|
+
import { tryMintGuardianActionGrant } from '../guardian-action-grant-minter.js';
|
|
81
82
|
|
|
82
83
|
const log = getLogger('runtime-http');
|
|
83
84
|
|
|
@@ -254,23 +255,13 @@ export async function handleChannelInbound(
|
|
|
254
255
|
|
|
255
256
|
if (denyNonMember) {
|
|
256
257
|
log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
|
|
257
|
-
if (body.replyCallbackUrl) {
|
|
258
|
-
try {
|
|
259
|
-
await deliverChannelReply(body.replyCallbackUrl, {
|
|
260
|
-
chatId: externalChatId,
|
|
261
|
-
text: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
|
|
262
|
-
assistantId,
|
|
263
|
-
}, bearerToken);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
258
|
|
|
269
259
|
// Notify the guardian about the access request so they can approve/deny.
|
|
270
260
|
// Only fires when a guardian binding exists and no duplicate pending
|
|
271
261
|
// request already exists for this requester.
|
|
262
|
+
let guardianNotified = false;
|
|
272
263
|
try {
|
|
273
|
-
notifyGuardianOfAccessRequest({
|
|
264
|
+
guardianNotified = notifyGuardianOfAccessRequest({
|
|
274
265
|
canonicalAssistantId,
|
|
275
266
|
sourceChannel,
|
|
276
267
|
externalChatId,
|
|
@@ -282,25 +273,89 @@ export async function handleChannelInbound(
|
|
|
282
273
|
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
283
274
|
}
|
|
284
275
|
|
|
285
|
-
return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (resolvedMember) {
|
|
290
|
-
if (resolvedMember.status !== 'active') {
|
|
291
|
-
log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
|
|
292
276
|
if (body.replyCallbackUrl) {
|
|
277
|
+
const replyText = guardianNotified
|
|
278
|
+
? "I've let my guardian know you'd like access. They'll send you a verification code if they approve your request."
|
|
279
|
+
: "Sorry, you haven't been approved to message this assistant.";
|
|
293
280
|
try {
|
|
294
281
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
295
282
|
chatId: externalChatId,
|
|
296
|
-
text:
|
|
283
|
+
text: replyText,
|
|
297
284
|
assistantId,
|
|
298
285
|
}, bearerToken);
|
|
299
286
|
} catch (err) {
|
|
300
287
|
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
301
288
|
}
|
|
302
289
|
}
|
|
303
|
-
|
|
290
|
+
|
|
291
|
+
return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (resolvedMember) {
|
|
296
|
+
if (resolvedMember.status !== 'active') {
|
|
297
|
+
// Same bypass logic as the no-member branch: verification codes and
|
|
298
|
+
// bootstrap commands must pass through even when the member record is
|
|
299
|
+
// revoked/blocked — otherwise the user can never re-verify.
|
|
300
|
+
let denyInactiveMember = true;
|
|
301
|
+
if (isGuardianVerifyCode) {
|
|
302
|
+
const hasPendingChallenge = !!getPendingChallenge(canonicalAssistantId, sourceChannel);
|
|
303
|
+
const hasActiveOutboundSession = !!findActiveSession(canonicalAssistantId, sourceChannel);
|
|
304
|
+
if (hasPendingChallenge || hasActiveOutboundSession) {
|
|
305
|
+
denyInactiveMember = false;
|
|
306
|
+
} else {
|
|
307
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, hasPendingChallenge, hasActiveOutboundSession }, 'Ingress ACL: inactive member verification bypass denied');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (isBootstrapCommand) {
|
|
311
|
+
const bootstrapPayload = (rawCommandIntentForAcl as Record<string, unknown>).payload as string;
|
|
312
|
+
const bootstrapTokenForAcl = bootstrapPayload.slice(3);
|
|
313
|
+
const bootstrapSessionForAcl = resolveBootstrapToken(canonicalAssistantId, sourceChannel, bootstrapTokenForAcl);
|
|
314
|
+
if (bootstrapSessionForAcl && bootstrapSessionForAcl.status === 'pending_bootstrap') {
|
|
315
|
+
denyInactiveMember = false;
|
|
316
|
+
} else {
|
|
317
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, hasValidBootstrapSession: false }, 'Ingress ACL: inactive member bootstrap bypass denied');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (denyInactiveMember) {
|
|
322
|
+
log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
|
|
323
|
+
|
|
324
|
+
// For revoked/pending members, notify the guardian so they can
|
|
325
|
+
// re-approve. Blocked members are intentionally excluded — the
|
|
326
|
+
// guardian already made an explicit decision to block them.
|
|
327
|
+
let guardianNotified = false;
|
|
328
|
+
if (resolvedMember.status !== 'blocked') {
|
|
329
|
+
try {
|
|
330
|
+
guardianNotified = notifyGuardianOfAccessRequest({
|
|
331
|
+
canonicalAssistantId,
|
|
332
|
+
sourceChannel,
|
|
333
|
+
externalChatId,
|
|
334
|
+
senderExternalUserId: body.senderExternalUserId,
|
|
335
|
+
senderName: body.senderName,
|
|
336
|
+
senderUsername: body.senderUsername,
|
|
337
|
+
});
|
|
338
|
+
} catch (err) {
|
|
339
|
+
log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (body.replyCallbackUrl) {
|
|
344
|
+
const replyText = guardianNotified
|
|
345
|
+
? "I've let my guardian know you'd like access. They'll send you a verification code if they approve your request."
|
|
346
|
+
: "Sorry, you haven't been approved to message this assistant.";
|
|
347
|
+
try {
|
|
348
|
+
await deliverChannelReply(body.replyCallbackUrl, {
|
|
349
|
+
chatId: externalChatId,
|
|
350
|
+
text: replyText,
|
|
351
|
+
assistantId,
|
|
352
|
+
}, bearerToken);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
|
|
358
|
+
}
|
|
304
359
|
}
|
|
305
360
|
|
|
306
361
|
if (resolvedMember.policy === 'deny') {
|
|
@@ -309,7 +364,7 @@ export async function handleChannelInbound(
|
|
|
309
364
|
try {
|
|
310
365
|
await deliverChannelReply(body.replyCallbackUrl, {
|
|
311
366
|
chatId: externalChatId,
|
|
312
|
-
text: "Sorry, you haven't been approved to message this assistant.
|
|
367
|
+
text: "Sorry, you haven't been approved to message this assistant.",
|
|
313
368
|
assistantId,
|
|
314
369
|
}, bearerToken);
|
|
315
370
|
} catch (err) {
|
|
@@ -855,6 +910,15 @@ export async function handleChannelInbound(
|
|
|
855
910
|
);
|
|
856
911
|
|
|
857
912
|
if (resolved) {
|
|
913
|
+
// Mint a scoped grant so the voice call can consume it
|
|
914
|
+
// for subsequent tool confirmations.
|
|
915
|
+
tryMintGuardianActionGrant({
|
|
916
|
+
resolvedRequest: resolved,
|
|
917
|
+
answerText,
|
|
918
|
+
decisionChannel: sourceChannel,
|
|
919
|
+
guardianExternalUserId: body.senderExternalUserId,
|
|
920
|
+
});
|
|
921
|
+
|
|
858
922
|
return Response.json({
|
|
859
923
|
accepted: true,
|
|
860
924
|
duplicate: false,
|
|
@@ -1408,7 +1472,7 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1408
1472
|
senderExternalUserId?: string;
|
|
1409
1473
|
senderName?: string;
|
|
1410
1474
|
senderUsername?: string;
|
|
1411
|
-
}):
|
|
1475
|
+
}): boolean {
|
|
1412
1476
|
const {
|
|
1413
1477
|
canonicalAssistantId,
|
|
1414
1478
|
sourceChannel,
|
|
@@ -1418,16 +1482,17 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1418
1482
|
senderUsername,
|
|
1419
1483
|
} = params;
|
|
1420
1484
|
|
|
1421
|
-
if (!senderExternalUserId) return;
|
|
1485
|
+
if (!senderExternalUserId) return false;
|
|
1422
1486
|
|
|
1423
1487
|
const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
|
|
1424
1488
|
if (!binding) {
|
|
1425
1489
|
log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
|
|
1426
|
-
return;
|
|
1490
|
+
return false;
|
|
1427
1491
|
}
|
|
1428
1492
|
|
|
1429
1493
|
// Deduplicate: skip if there is already a pending approval request for
|
|
1430
|
-
// the same requester on this channel.
|
|
1494
|
+
// the same requester on this channel. Still return true — the guardian
|
|
1495
|
+
// was already notified for this request.
|
|
1431
1496
|
const existing = findPendingAccessRequestForRequester(
|
|
1432
1497
|
canonicalAssistantId,
|
|
1433
1498
|
sourceChannel,
|
|
@@ -1439,7 +1504,7 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1439
1504
|
{ sourceChannel, senderExternalUserId, existingId: existing.id },
|
|
1440
1505
|
'Skipping duplicate access request notification',
|
|
1441
1506
|
);
|
|
1442
|
-
return;
|
|
1507
|
+
return true;
|
|
1443
1508
|
}
|
|
1444
1509
|
|
|
1445
1510
|
const senderIdentifier = senderName || senderUsername || senderExternalUserId;
|
|
@@ -1491,6 +1556,8 @@ function notifyGuardianOfAccessRequest(params: {
|
|
|
1491
1556
|
{ sourceChannel, senderExternalUserId, senderIdentifier },
|
|
1492
1557
|
'Guardian notified of non-member access request',
|
|
1493
1558
|
);
|
|
1559
|
+
|
|
1560
|
+
return true;
|
|
1494
1561
|
}
|
|
1495
1562
|
|
|
1496
1563
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON serialization and deterministic SHA-256 hash for tool
|
|
3
|
+
* approval signatures.
|
|
4
|
+
*
|
|
5
|
+
* Producers (grant creators) and consumers (grant matchers) must use the
|
|
6
|
+
* same serialization to ensure digest equality. The algorithm:
|
|
7
|
+
* 1. Sort object keys recursively (depth-first).
|
|
8
|
+
* 2. Convert to a canonical JSON string with no whitespace.
|
|
9
|
+
* 3. SHA-256 hash the UTF-8 bytes and return a lowercase hex digest.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Canonical JSON serialization
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively sort all object keys and return a deterministic JSON string.
|
|
20
|
+
*
|
|
21
|
+
* Handles nested objects, arrays (element order preserved), and primitive
|
|
22
|
+
* values. `undefined` values inside objects are omitted (matching
|
|
23
|
+
* JSON.stringify semantics). `null` is preserved.
|
|
24
|
+
*/
|
|
25
|
+
export function canonicalJsonSerialize(value: unknown): string {
|
|
26
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
30
|
+
if (value === null || value === undefined) return value;
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map(sortKeysDeep);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === 'object') {
|
|
37
|
+
const sorted: Record<string, unknown> = {};
|
|
38
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
41
|
+
}
|
|
42
|
+
return sorted;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Primitive — number, string, boolean
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Digest computation
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute a deterministic SHA-256 hex digest over the canonical
|
|
55
|
+
* serialization of a tool invocation's name and input.
|
|
56
|
+
*
|
|
57
|
+
* The digest covers `{ toolName, input }` so that two invocations of the
|
|
58
|
+
* same tool with identical (deeply-equal) inputs always produce the same
|
|
59
|
+
* hash, regardless of key ordering in the original input object.
|
|
60
|
+
*/
|
|
61
|
+
export function computeToolApprovalDigest(
|
|
62
|
+
toolName: string,
|
|
63
|
+
input: Record<string, unknown>,
|
|
64
|
+
): string {
|
|
65
|
+
const payload = canonicalJsonSerialize({ input, toolName });
|
|
66
|
+
return createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
67
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export type RemoteSkillProvider = 'clawhub' | 'skillssh';
|
|
2
|
+
|
|
3
|
+
export type SkillsShRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'unknown';
|
|
4
|
+
export type SkillsShRiskThreshold = Exclude<SkillsShRisk, 'unknown'>;
|
|
5
|
+
|
|
6
|
+
export interface RemoteSkillPolicy {
|
|
7
|
+
/**
|
|
8
|
+
* When true, suspicious skills are excluded from installable lists
|
|
9
|
+
* and blocked from installation.
|
|
10
|
+
*/
|
|
11
|
+
blockSuspicious: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* When true, malware-blocked skills are excluded from installable lists
|
|
14
|
+
* and blocked from installation.
|
|
15
|
+
*/
|
|
16
|
+
blockMalware: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Maximum allowed Skills.sh audit risk. Anything above this threshold is blocked.
|
|
19
|
+
*/
|
|
20
|
+
maxSkillsShRisk: SkillsShRiskThreshold;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClawhubModerationState {
|
|
24
|
+
isSuspicious?: boolean;
|
|
25
|
+
isMalwareBlocked?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SkillsShAuditState {
|
|
29
|
+
risk?: SkillsShRisk | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RemoteSkillCandidateBase {
|
|
33
|
+
provider: RemoteSkillProvider;
|
|
34
|
+
slug: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClawhubRemoteSkillCandidate extends RemoteSkillCandidateBase {
|
|
38
|
+
provider: 'clawhub';
|
|
39
|
+
moderation?: ClawhubModerationState | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SkillsShRemoteSkillCandidate extends RemoteSkillCandidateBase {
|
|
43
|
+
provider: 'skillssh';
|
|
44
|
+
audit?: SkillsShAuditState | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RemoteSkillCandidate =
|
|
48
|
+
| ClawhubRemoteSkillCandidate
|
|
49
|
+
| SkillsShRemoteSkillCandidate;
|
|
50
|
+
|
|
51
|
+
export type RemoteSkillDenyReason =
|
|
52
|
+
| 'clawhub_suspicious'
|
|
53
|
+
| 'clawhub_malware_blocked'
|
|
54
|
+
| 'clawhub_moderation_missing'
|
|
55
|
+
| 'skillssh_risk_exceeds_threshold';
|
|
56
|
+
|
|
57
|
+
export type RemoteSkillInstallDecision =
|
|
58
|
+
| { ok: true }
|
|
59
|
+
| { ok: false; reason: RemoteSkillDenyReason };
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_REMOTE_SKILL_POLICY: Readonly<RemoteSkillPolicy> = Object.freeze({
|
|
62
|
+
blockSuspicious: true,
|
|
63
|
+
blockMalware: true,
|
|
64
|
+
maxSkillsShRisk: 'medium',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const SKILLS_SH_RISK_RANK: Record<SkillsShRisk, number> = {
|
|
68
|
+
safe: 0,
|
|
69
|
+
low: 1,
|
|
70
|
+
medium: 2,
|
|
71
|
+
high: 3,
|
|
72
|
+
critical: 4,
|
|
73
|
+
// Fail closed when risk is unknown.
|
|
74
|
+
unknown: 5,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function normalizeSkillsShRisk(audit: SkillsShAuditState | null | undefined): SkillsShRisk {
|
|
78
|
+
const risk = audit?.risk;
|
|
79
|
+
if (risk == null) return 'unknown';
|
|
80
|
+
// Coerce unrecognized risk labels to 'unknown' so we fail closed.
|
|
81
|
+
if (!Object.hasOwn(SKILLS_SH_RISK_RANK, risk)) return 'unknown';
|
|
82
|
+
return risk;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function exceedsSkillsShRiskThreshold(
|
|
86
|
+
audit: SkillsShAuditState | null | undefined,
|
|
87
|
+
threshold: SkillsShRiskThreshold,
|
|
88
|
+
): boolean {
|
|
89
|
+
const actualRisk = normalizeSkillsShRisk(audit);
|
|
90
|
+
return SKILLS_SH_RISK_RANK[actualRisk] > SKILLS_SH_RISK_RANK[threshold];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function evaluateRemoteSkillInstall(
|
|
94
|
+
candidate: RemoteSkillCandidate,
|
|
95
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
96
|
+
): RemoteSkillInstallDecision {
|
|
97
|
+
if (candidate.provider === 'clawhub') {
|
|
98
|
+
// Fail closed: block Clawhub skills when moderation data is missing.
|
|
99
|
+
if (candidate.moderation == null) {
|
|
100
|
+
return { ok: false, reason: 'clawhub_moderation_missing' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (policy.blockMalware && candidate.moderation.isMalwareBlocked === true) {
|
|
104
|
+
return { ok: false, reason: 'clawhub_malware_blocked' };
|
|
105
|
+
}
|
|
106
|
+
if (policy.blockSuspicious && candidate.moderation.isSuspicious === true) {
|
|
107
|
+
return { ok: false, reason: 'clawhub_suspicious' };
|
|
108
|
+
}
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (exceedsSkillsShRiskThreshold(candidate.audit, policy.maxSkillsShRisk)) {
|
|
113
|
+
return { ok: false, reason: 'skillssh_risk_exceeds_threshold' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isRemoteSkillInstallable(
|
|
120
|
+
candidate: RemoteSkillCandidate,
|
|
121
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
122
|
+
): boolean {
|
|
123
|
+
return evaluateRemoteSkillInstall(candidate, policy).ok;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function filterInstallableRemoteSkills<T extends RemoteSkillCandidate>(
|
|
127
|
+
skills: T[],
|
|
128
|
+
policy: RemoteSkillPolicy = DEFAULT_REMOTE_SKILL_POLICY,
|
|
129
|
+
): T[] {
|
|
130
|
+
return skills.filter((skill) => isRemoteSkillInstallable(skill, policy));
|
|
131
|
+
}
|