@vellumai/assistant 0.3.3 → 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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -5,6 +5,7 @@ import { getOrCreateConversation } from '../../memory/conversation-key-store.js'
5
5
  import * as attachmentsStore from '../../memory/attachments-store.js';
6
6
  import * as runsStore from '../../memory/runs-store.js';
7
7
  import { addRule } from '../../permissions/trust-store.js';
8
+ import { getTool } from '../../tools/registry.js';
8
9
  import { getLogger } from '../../util/logger.js';
9
10
  import type { RunOrchestrator } from '../run-orchestrator.js';
10
11
 
@@ -200,8 +201,13 @@ export async function handleAddTrustRule(
200
201
  }
201
202
 
202
203
  try {
204
+ // Only persist executionTarget for skill-origin tools — core tools don't
205
+ // set it in their PolicyContext, so a persisted value would prevent the
206
+ // rule from ever matching on subsequent permission checks.
207
+ const tool = getTool(confirmation.toolName);
208
+ const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
203
209
  addRule(confirmation.toolName, pattern, scope, decision, undefined, {
204
- executionTarget: confirmation.executionTarget,
210
+ executionTarget,
205
211
  });
206
212
  log.info(
207
213
  { tool: confirmation.toolName, pattern, scope, decision, runId },
@@ -18,6 +18,7 @@ import type { Run } from '../memory/runs-store.js';
18
18
  import type { Session } from '../daemon/session.js';
19
19
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
20
20
  import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
21
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
21
22
  import type { UserDecision } from '../permissions/types.js';
22
23
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
23
24
  import { IngressBlockedError } from '../util/errors.js';
@@ -37,7 +38,11 @@ interface PendingRunState {
37
38
  }
38
39
 
39
40
  export interface RunOrchestratorDeps {
40
- getOrCreateSession: (conversationId: string) => Promise<Session>;
41
+ getOrCreateSession: (conversationId: string, transport?: {
42
+ channelId: string;
43
+ hints?: string[];
44
+ uxBrief?: string;
45
+ }) => Promise<Session>;
41
46
  resolveAttachments: (attachmentIds: string[]) => Array<{
42
47
  id: string;
43
48
  filename: string;
@@ -67,6 +72,20 @@ export interface RunStartOptions {
67
72
  * default 'http-api'.
68
73
  */
69
74
  sourceChannel?: string;
75
+ /**
76
+ * Transport hints from sourceMetadata (e.g. reply-context cues).
77
+ * Forwarded to the session so the agent loop can incorporate them.
78
+ */
79
+ hints?: string[];
80
+ /**
81
+ * Brief UX context from sourceMetadata (e.g. UI surface description).
82
+ * Forwarded to the session so the agent loop can tailor its response.
83
+ */
84
+ uxBrief?: string;
85
+ /** Assistant scope for multi-assistant channels. */
86
+ assistantId?: string;
87
+ /** Guardian trust/identity context for the inbound actor. */
88
+ guardianContext?: GuardianRuntimeContext;
70
89
  }
71
90
 
72
91
  // ---------------------------------------------------------------------------
@@ -104,7 +123,17 @@ export class RunOrchestrator {
104
123
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
105
124
  }
106
125
 
107
- const session = await this.deps.getOrCreateSession(conversationId);
126
+ // Build transport metadata when channel context is available so the
127
+ // session receives the same hints/uxBrief as the non-orchestrator path.
128
+ const transport = options?.sourceChannel
129
+ ? {
130
+ channelId: options.sourceChannel,
131
+ hints: options.hints,
132
+ uxBrief: options.uxBrief,
133
+ }
134
+ : undefined;
135
+
136
+ const session = await this.deps.getOrCreateSession(conversationId, transport);
108
137
 
109
138
  if (session.isProcessing()) {
110
139
  throw new Error('Session is already processing a message');
@@ -121,6 +150,8 @@ export class RunOrchestrator {
121
150
  ...session.memoryPolicy,
122
151
  strictSideEffects,
123
152
  };
153
+ session.setAssistantId(options?.assistantId ?? 'self');
154
+ session.setGuardianContext(options?.guardianContext ?? null);
124
155
 
125
156
  const attachments = attachmentIds
126
157
  ? this.deps.resolveAttachments(attachmentIds)
@@ -201,6 +232,8 @@ export class RunOrchestrator {
201
232
  // Reset channel capabilities so a subsequent IPC/desktop session on the
202
233
  // same conversation is not incorrectly treated as an HTTP-API client.
203
234
  session.setChannelCapabilities(null);
235
+ session.setGuardianContext(null);
236
+ session.setAssistantId('self');
204
237
  // Reset the session's client callback to a no-op so the stale
205
238
  // closure doesn't intercept events from future runs on the same session.
206
239
  // Set hasNoClient=true here since the run is done and no HTTP caller
@@ -251,13 +284,20 @@ export class RunOrchestrator {
251
284
  * - `'run_not_found'` – no run exists with the given ID
252
285
  * - `'no_pending_decision'` – run exists but is not awaiting a confirmation
253
286
  */
254
- submitDecision(runId: string, decision: UserDecision): 'applied' | 'run_not_found' | 'no_pending_decision' {
287
+ submitDecision(
288
+ runId: string,
289
+ decision: UserDecision,
290
+ decisionContext?: string,
291
+ ): 'applied' | 'run_not_found' | 'no_pending_decision' {
255
292
  const pendingState = this.pending.get(runId);
256
293
  if (pendingState) {
257
294
  runsStore.clearRunConfirmation(runId);
258
295
  pendingState.session.handleConfirmationResponse(
259
296
  pendingState.prompterRequestId,
260
297
  decision,
298
+ undefined,
299
+ undefined,
300
+ decisionContext,
261
301
  );
262
302
  this.pending.delete(runId);
263
303
  return 'applied';
@@ -457,6 +457,216 @@ function scanEntropy(
457
457
  return matches;
458
458
  }
459
459
 
460
+ // ---------------------------------------------------------------------------
461
+ // Encoded secret detection — decode + re-scan pass
462
+ // ---------------------------------------------------------------------------
463
+
464
+ /**
465
+ * Find percent-encoded segments containing 3+ encoded bytes, using a linear
466
+ * scan instead of a regex with nested quantifiers (which caused catastrophic
467
+ * backtracking on long near-miss inputs).
468
+ */
469
+ function findPercentEncodedSegments(text: string): Array<{ start: number; end: number; match: string }> {
470
+ const results: Array<{ start: number; end: number; match: string }> = [];
471
+ const len = text.length;
472
+ const isUrlChar = (ch: string) => /[A-Za-z0-9_.~+/\-]/.test(ch);
473
+ const isHexDigit = (ch: string) => /[0-9A-Fa-f]/.test(ch);
474
+
475
+ let i = 0;
476
+ while (i < len) {
477
+ // Look for the start of a percent-encoded segment
478
+ if (text[i] !== '%' && !isUrlChar(text[i])) { i++; continue; }
479
+
480
+ // Walk a candidate segment of URL-safe chars and %XX sequences
481
+ const start = i;
482
+ let pctCount = 0;
483
+ while (i < len) {
484
+ if (text[i] === '%' && i + 2 < len && isHexDigit(text[i + 1]) && isHexDigit(text[i + 2])) {
485
+ pctCount++;
486
+ i += 3;
487
+ } else if (isUrlChar(text[i])) {
488
+ i++;
489
+ } else {
490
+ break;
491
+ }
492
+ }
493
+
494
+ if (pctCount >= 3) {
495
+ results.push({ start, end: i, match: text.slice(start, i) });
496
+ }
497
+ // Avoid re-scanning the same position if we didn't advance
498
+ if (i === start) i++;
499
+ }
500
+ return results;
501
+ }
502
+
503
+ /** Hex-escape sequences: \xHH patterns (3+ consecutive) */
504
+ const HEX_ESCAPE_RE = /(?:\\x[0-9A-Fa-f]{2}){3,}/g;
505
+
506
+ /** Candidate base64 segments — 24+ chars that could encode a secret (≥18 decoded bytes) */
507
+ const ENCODED_BASE64_RE = /\b([A-Za-z0-9+/\-_]{24,}={0,3})(?=\W|$)/g;
508
+
509
+ /** Continuous hex-encoded bytes — 32+ hex chars (16+ bytes decoded) */
510
+ const CONTINUOUS_HEX_RE = /\b([0-9a-fA-F]{32,})\b/g;
511
+
512
+ /** Check if decoded content is printable ASCII text */
513
+ function isPrintableText(s: string): boolean {
514
+ return s.length > 0 && /^[\x20-\x7E\t\n\r]+$/.test(s);
515
+ }
516
+
517
+ function tryDecodeBase64(encoded: string): string | null {
518
+ try {
519
+ // Handle both standard and URL-safe base64
520
+ const standardized = encoded.replace(/-/g, '+').replace(/_/g, '/');
521
+ const decoded = Buffer.from(standardized, 'base64').toString('utf-8');
522
+ if (!isPrintableText(decoded)) return null;
523
+ // Verify round-trip to reject garbage decodes
524
+ const reEncoded = Buffer.from(decoded, 'utf-8').toString('base64').replace(/=+$/, '');
525
+ if (standardized.replace(/=+$/, '') !== reEncoded) return null;
526
+ return decoded;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ function tryDecodePercentEncoded(encoded: string): string | null {
533
+ try {
534
+ const decoded = decodeURIComponent(encoded);
535
+ if (decoded === encoded) return null;
536
+ if (!isPrintableText(decoded)) return null;
537
+ return decoded;
538
+ } catch {
539
+ return null;
540
+ }
541
+ }
542
+
543
+ function tryDecodeHexEscapes(encoded: string): string | null {
544
+ try {
545
+ const decoded = encoded.replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) =>
546
+ String.fromCharCode(parseInt(hex, 16)),
547
+ );
548
+ if (decoded === encoded) return null;
549
+ if (!isPrintableText(decoded)) return null;
550
+ return decoded;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+
556
+ function tryDecodeContinuousHex(encoded: string): string | null {
557
+ try {
558
+ // Odd-length strings can't be decoded as pairs of hex digits
559
+ if (encoded.length % 2 !== 0) return null;
560
+ // Decode pairs of hex digits to bytes
561
+ const bytes: number[] = [];
562
+ for (let i = 0; i < encoded.length; i += 2) {
563
+ bytes.push(parseInt(encoded.slice(i, i + 2), 16));
564
+ }
565
+ const decoded = String.fromCharCode(...bytes);
566
+ if (!isPrintableText(decoded)) return null;
567
+ return decoded;
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /** Check if an encoded segment overlaps with any existing match range */
574
+ function overlapsExisting(start: number, end: number, ranges: Set<string>): boolean {
575
+ for (const rangeKey of ranges) {
576
+ const sep = rangeKey.indexOf(':');
577
+ const rStart = Number(rangeKey.slice(0, sep));
578
+ const rEnd = Number(rangeKey.slice(sep + 1));
579
+ if (start < rEnd && end > rStart) return true;
580
+ }
581
+ return false;
582
+ }
583
+
584
+ /**
585
+ * Scan for encoded secrets by decoding candidate segments and running
586
+ * pattern matching on the decoded content. Catches base64-encoded,
587
+ * hex-encoded, and percent-encoded secrets that raw regex would miss.
588
+ */
589
+ function scanEncoded(
590
+ text: string,
591
+ existingRanges: Set<string>,
592
+ ): SecretMatch[] {
593
+ const matches: SecretMatch[] = [];
594
+
595
+ // Helper: try to match decoded content against known secret patterns
596
+ const tryMatchDecoded = (
597
+ encoded: string,
598
+ decoded: string,
599
+ startIndex: number,
600
+ endIndex: number,
601
+ encoding: string,
602
+ ) => {
603
+ for (const pattern of PATTERNS) {
604
+ pattern.regex.lastIndex = 0;
605
+ let pm: RegExpExecArray | null;
606
+ while ((pm = pattern.regex.exec(decoded)) !== null) {
607
+ const value = pm[1] ?? pm[0];
608
+ if (isPlaceholder(value)) continue;
609
+ if (isAllowlisted(value)) continue;
610
+ if (pattern.type === 'AWS Secret Key' && !isLikelyAwsSecret(value)) continue;
611
+
612
+ const key = `${startIndex}:${endIndex}`;
613
+ existingRanges.add(key);
614
+ matches.push({
615
+ type: `${pattern.type} (${encoding})`,
616
+ startIndex,
617
+ endIndex,
618
+ redactedValue: redact(encoded),
619
+ });
620
+ return;
621
+ }
622
+ }
623
+ };
624
+
625
+ // Percent-encoded segments: use linear-time scanner instead of regex
626
+ if (text.includes('%')) {
627
+ for (const seg of findPercentEncodedSegments(text)) {
628
+ if (seg.match.length > 1000) continue;
629
+ if (overlapsExisting(seg.start, seg.end, existingRanges)) continue;
630
+ const decoded = tryDecodePercentEncoded(seg.match);
631
+ if (!decoded) continue;
632
+ tryMatchDecoded(seg.match, decoded, seg.start, seg.end, 'percent-encoded');
633
+ }
634
+ }
635
+
636
+ // Regex-based decoders for the remaining encodings
637
+ const decoders: Array<{
638
+ regex: RegExp;
639
+ decode: (s: string) => string | null;
640
+ encoding: string;
641
+ quickCheck?: (t: string) => boolean;
642
+ }> = [
643
+ { regex: HEX_ESCAPE_RE, decode: tryDecodeHexEscapes, encoding: 'hex-escaped', quickCheck: (t) => t.includes('\\x') },
644
+ { regex: ENCODED_BASE64_RE, decode: tryDecodeBase64, encoding: 'base64-encoded' },
645
+ { regex: CONTINUOUS_HEX_RE, decode: tryDecodeContinuousHex, encoding: 'hex-encoded' },
646
+ ];
647
+
648
+ for (const { regex, decode, encoding, quickCheck } of decoders) {
649
+ if (quickCheck && !quickCheck(text)) continue;
650
+ regex.lastIndex = 0;
651
+ let m: RegExpExecArray | null;
652
+ while ((m = regex.exec(text)) !== null) {
653
+ const encoded = m[1] ?? m[0];
654
+ if (encoded.length > 1000) continue;
655
+ const startIndex = m.index + (m[0].indexOf(encoded));
656
+ const endIndex = startIndex + encoded.length;
657
+
658
+ if (overlapsExisting(startIndex, endIndex, existingRanges)) continue;
659
+
660
+ const decoded = decode(encoded);
661
+ if (!decoded) continue;
662
+
663
+ tryMatchDecoded(encoded, decoded, startIndex, endIndex, encoding);
664
+ }
665
+ }
666
+
667
+ return matches;
668
+ }
669
+
460
670
  // ---------------------------------------------------------------------------
461
671
  // Scan function
462
672
  // ---------------------------------------------------------------------------
@@ -508,6 +718,10 @@ export function scanText(text: string, entropyConfig?: Partial<EntropyConfig>):
508
718
  const entropyMatches = scanEntropy(text, eConfig, seen);
509
719
  matches.push(...entropyMatches);
510
720
 
721
+ // Encoded secret detection — decode candidate segments and re-scan
722
+ const encodedMatches = scanEncoded(text, seen);
723
+ matches.push(...encodedMatches);
724
+
511
725
  // Sort by position; at same start, wider match first so redaction covers the full span
512
726
  matches.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex);
513
727
  return matches;
@@ -547,4 +761,8 @@ export {
547
761
  redact as _redact,
548
762
  PATTERNS as _PATTERNS,
549
763
  hasSecretContext as _hasSecretContext,
764
+ tryDecodeBase64 as _tryDecodeBase64,
765
+ tryDecodePercentEncoded as _tryDecodePercentEncoded,
766
+ tryDecodeHexEscapes as _tryDecodeHexEscapes,
767
+ tryDecodeContinuousHex as _tryDecodeContinuousHex,
550
768
  };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared frontmatter parsing for SKILL.md files.
3
+ *
4
+ * Frontmatter is a YAML-like block delimited by `---` at the top of a file.
5
+ * This module provides a single implementation used by the skill catalog loader,
6
+ * the Vellum catalog installer, and the CC command registry.
7
+ */
8
+
9
+ /** Matches a `---` delimited frontmatter block at the start of a file. */
10
+ export const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
11
+
12
+ export interface FrontmatterParseResult {
13
+ /** Key-value pairs extracted from the frontmatter block. */
14
+ fields: Record<string, string>;
15
+ /** The remaining file content after the frontmatter block. */
16
+ body: string;
17
+ }
18
+
19
+ /**
20
+ * Parse frontmatter fields from file content.
21
+ *
22
+ * Extracts key-value pairs from the `---` delimited block at the top of the
23
+ * file. Handles single- and double-quoted values, and unescapes common escape
24
+ * sequences (`\n`, `\r`, `\\`, `\"`) in double-quoted values.
25
+ *
26
+ * Returns `null` if no frontmatter block is found.
27
+ */
28
+ export function parseFrontmatterFields(content: string): FrontmatterParseResult | null {
29
+ const match = content.match(FRONTMATTER_REGEX);
30
+ if (!match) return null;
31
+
32
+ const frontmatter = match[1];
33
+ const fields: Record<string, string> = {};
34
+
35
+ for (const line of frontmatter.split(/\r?\n/)) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed || trimmed.startsWith('#')) continue;
38
+ const separatorIndex = trimmed.indexOf(':');
39
+ if (separatorIndex === -1) continue;
40
+
41
+ const key = trimmed.slice(0, separatorIndex).trim();
42
+ let value = trimmed.slice(separatorIndex + 1).trim();
43
+
44
+ const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
45
+ const isSingleQuoted = value.startsWith("'") && value.endsWith("'");
46
+ if (isDoubleQuoted || isSingleQuoted) {
47
+ value = value.slice(1, -1);
48
+ if (isDoubleQuoted) {
49
+ // Unescape sequences produced by buildSkillMarkdown's esc().
50
+ // Only for double-quoted values — single-quoted YAML treats backslashes literally.
51
+ // Single-pass to avoid misinterpreting \\n (escaped backslash + n) as a newline.
52
+ value = value.replace(/\\(["\\nr])/g, (_, ch) => {
53
+ if (ch === 'n') return '\n';
54
+ if (ch === 'r') return '\r';
55
+ return ch; // handles \\ → \ and \" → "
56
+ });
57
+ }
58
+ }
59
+ fields[key] = value;
60
+ }
61
+
62
+ return { fields, body: content.slice(match[0].length) };
63
+ }
@@ -155,6 +155,10 @@ export function formatUnknownSlashSkillMessage(
155
155
  /**
156
156
  * Rewrite user input for a known slash command into a model-facing prompt
157
157
  * that explicitly instructs the model to invoke the skill.
158
+ *
159
+ * For the claude-code skill, trailing arguments are routed via the `command`
160
+ * input (not `prompt`) so that .claude/commands/*.md templates are loaded
161
+ * and $ARGUMENTS substitution is applied.
158
162
  */
159
163
  export function rewriteKnownSlashCommandPrompt(params: {
160
164
  rawInput: string;
@@ -162,6 +166,25 @@ export function rewriteKnownSlashCommandPrompt(params: {
162
166
  skillName: string;
163
167
  trailingArgs: string;
164
168
  }): string {
169
+ // For the claude-code skill, route trailing args through the `command` input
170
+ // so CC command templates (.claude/commands/*.md) are loaded and $ARGUMENTS
171
+ // substitution is applied, rather than sending them as a raw prompt.
172
+ if (params.skillId === 'claude-code' && params.trailingArgs) {
173
+ // Extract the command name (first word of trailing args) and remaining arguments
174
+ const parts = params.trailingArgs.split(/\s+/);
175
+ const commandName = parts[0];
176
+ const commandArgs = parts.slice(1).join(' ');
177
+
178
+ const lines = [
179
+ `The user invoked the slash command \`/${params.skillId}\`.`,
180
+ `Execute the Claude Code command "${commandName}" using the claude_code tool with command="${commandName}".`,
181
+ ];
182
+ if (commandArgs) {
183
+ lines.push(`Pass the following as the \`arguments\` input: ${commandArgs}`);
184
+ }
185
+ return lines.join('\n');
186
+ }
187
+
165
188
  const lines = [
166
189
  `The user invoked the slash command \`/${params.skillId}\`.`,
167
190
  `Please invoke the "${params.skillName}" skill (ID: ${params.skillId}).`,
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import type { CatalogEntry } from '../tools/skills/vellum-catalog.js';
5
+ import { getLogger } from '../util/logger.js';
6
+
7
+ const log = getLogger('vellum-catalog-remote');
8
+
9
+ const GITHUB_RAW_BASE =
10
+ 'https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/assistant/src/config/vellum-skills';
11
+
12
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
13
+
14
+ interface CatalogManifest {
15
+ version: number;
16
+ skills: CatalogEntry[];
17
+ }
18
+
19
+ let cachedEntries: CatalogEntry[] | null = null;
20
+ let cacheTimestamp = 0;
21
+
22
+ function getBundledCatalogPath(): string {
23
+ return join(import.meta.dir, '..', 'config', 'vellum-skills', 'catalog.json');
24
+ }
25
+
26
+ function loadBundledCatalog(): CatalogEntry[] {
27
+ try {
28
+ const raw = readFileSync(getBundledCatalogPath(), 'utf-8');
29
+ const manifest: CatalogManifest = JSON.parse(raw);
30
+ return manifest.skills ?? [];
31
+ } catch (err) {
32
+ log.warn({ err }, 'Failed to read bundled catalog.json');
33
+ return [];
34
+ }
35
+ }
36
+
37
+ function getBundledSkillContent(skillId: string): string | null {
38
+ try {
39
+ const skillPath = join(import.meta.dir, '..', 'config', 'vellum-skills', skillId, 'SKILL.md');
40
+ return readFileSync(skillPath, 'utf-8');
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Fetch catalog entries (cached, async). Falls back to bundled copy. */
47
+ export async function fetchCatalogEntries(): Promise<CatalogEntry[]> {
48
+ const now = Date.now();
49
+ if (cachedEntries && now - cacheTimestamp < CACHE_TTL_MS) {
50
+ return cachedEntries;
51
+ }
52
+
53
+ try {
54
+ const url = `${GITHUB_RAW_BASE}/catalog.json`;
55
+ const response = await fetch(url, {
56
+ signal: AbortSignal.timeout(5000),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
61
+ }
62
+
63
+ const manifest: CatalogManifest = await response.json();
64
+ const skills = manifest.skills;
65
+ if (!Array.isArray(skills) || skills.length === 0) {
66
+ throw new Error('Remote catalog has invalid or empty skills array');
67
+ }
68
+ cachedEntries = skills;
69
+ cacheTimestamp = now;
70
+ log.info({ count: cachedEntries.length }, 'Fetched remote vellum-skills catalog');
71
+ return cachedEntries;
72
+ } catch (err) {
73
+ log.warn({ err }, 'Failed to fetch remote catalog, falling back to bundled copy');
74
+ const bundled = loadBundledCatalog();
75
+ // Cache the bundled result too so we don't re-fetch on every call during outage
76
+ cachedEntries = bundled;
77
+ cacheTimestamp = now;
78
+ return bundled;
79
+ }
80
+ }
81
+
82
+ /** Fetch a skill's SKILL.md content from GitHub. Falls back to bundled copy. */
83
+ export async function fetchSkillContent(skillId: string): Promise<string | null> {
84
+ try {
85
+ const url = `${GITHUB_RAW_BASE}/${encodeURIComponent(skillId)}/SKILL.md`;
86
+ const response = await fetch(url, {
87
+ signal: AbortSignal.timeout(10000),
88
+ });
89
+
90
+ if (!response.ok) {
91
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
92
+ }
93
+
94
+ const content = await response.text();
95
+ log.info({ skillId }, 'Fetched remote SKILL.md');
96
+ return content;
97
+ } catch (err) {
98
+ log.warn({ err, skillId }, 'Failed to fetch remote SKILL.md, falling back to bundled copy');
99
+ return getBundledSkillContent(skillId);
100
+ }
101
+ }
102
+
103
+ /** Check if a skill ID exists in the remote catalog. */
104
+ export async function checkVellumSkill(skillId: string): Promise<boolean> {
105
+ const entries = await fetchCatalogEntries();
106
+ return entries.some((e) => e.id === skillId);
107
+ }
@@ -41,8 +41,8 @@ function formatBytes(bytes: number): string {
41
41
  /**
42
42
  * Load an attachment row (including base64 data) by its primary key.
43
43
  *
44
- * Not scoped by assistantId because ToolContext doesn't carry it.
45
- * Cross-thread isolation is enforced by the visibility check in execute().
44
+ * Not scoped by assistantId because attachment access is enforced by
45
+ * conversation visibility checks in execute().
46
46
  */
47
47
  function loadAttachmentById(
48
48
  attachmentId: string,