@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.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/docs/architecture/security.md +80 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  5. package/src/__tests__/call-controller.test.ts +170 -0
  6. package/src/__tests__/checker.test.ts +60 -0
  7. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  8. package/src/__tests__/guardian-dispatch.test.ts +61 -1
  9. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  10. package/src/__tests__/ipc-snapshot.test.ts +1 -0
  11. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  12. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  13. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  14. package/src/__tests__/trust-store.test.ts +2 -0
  15. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  16. package/src/calls/call-controller.ts +27 -6
  17. package/src/calls/call-domain.ts +12 -0
  18. package/src/calls/guardian-dispatch.ts +8 -0
  19. package/src/calls/relay-server.ts +13 -0
  20. package/src/calls/voice-session-bridge.ts +42 -3
  21. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  22. package/src/config/schema.ts +6 -0
  23. package/src/config/skills-schema.ts +27 -0
  24. package/src/daemon/handlers/config-channels.ts +18 -0
  25. package/src/daemon/handlers/skills.ts +45 -2
  26. package/src/daemon/ipc-contract/skills.ts +1 -0
  27. package/src/daemon/session-process.ts +12 -0
  28. package/src/memory/db-init.ts +9 -1
  29. package/src/memory/embedding-local.ts +16 -7
  30. package/src/memory/guardian-action-store.ts +8 -0
  31. package/src/memory/guardian-verification.ts +1 -1
  32. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  33. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  34. package/src/memory/migrations/index.ts +2 -0
  35. package/src/memory/schema.ts +30 -0
  36. package/src/memory/scoped-approval-grants.ts +509 -0
  37. package/src/permissions/checker.ts +27 -0
  38. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  39. package/src/runtime/routes/guardian-approval-interception.ts +116 -0
  40. package/src/runtime/routes/inbound-message-handler.ts +94 -27
  41. package/src/security/tool-approval-digest.ts +67 -0
  42. 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: "Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite.",
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
- return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
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. You can ask its Guardian for an invite.",
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
- }): void {
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
+ }