vellum 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -156,7 +156,7 @@ describe('scheduler RRULE execution', () => {
156
156
  expect(runs.length).toBeGreaterThanOrEqual(1);
157
157
  });
158
158
 
159
- test('ended RRULE (UNTIL in past) is not repeatedly claimed', async () => {
159
+ test('expired RRULE fires one final due run then is disabled', async () => {
160
160
  const endedExpr = buildEndedRrule();
161
161
 
162
162
  // Insert directly via raw SQL because createSchedule would throw when
@@ -167,7 +167,7 @@ describe('scheduler RRULE execution', () => {
167
167
  getRawDb().run(
168
168
  `INSERT INTO cron_jobs (id, name, enabled, cron_expression, schedule_syntax, timezone, message, next_run_at, last_run_at, last_status, retry_count, created_by, created_at, updated_at)
169
169
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
170
- [id, 'Ended RRULE', 1, endedExpr, 'rrule', null, 'Should not fire', now - 1000, null, null, 0, 'agent', now, now],
170
+ [id, 'Ended RRULE', 1, endedExpr, 'rrule', null, 'Final expired run', now - 1000, null, null, 0, 'agent', now, now],
171
171
  );
172
172
 
173
173
  const processedMessages: string[] = [];
@@ -175,16 +175,35 @@ describe('scheduler RRULE execution', () => {
175
175
  processedMessages.push(message);
176
176
  };
177
177
 
178
- const scheduler = startScheduler(processMessage, () => {}, () => {});
178
+ // First tick: the expired schedule should fire its final due run
179
+ const scheduler1 = startScheduler(processMessage, () => {}, () => {});
179
180
  await new Promise(resolve => setTimeout(resolve, 500));
180
- scheduler.stop();
181
+ scheduler1.stop();
181
182
 
182
- // The ended RRULE should NOT have fired
183
- expect(processedMessages).not.toContain('Should not fire');
183
+ // The message IS delivered once
184
+ expect(processedMessages).toContain('Final expired run');
184
185
 
185
- // No runs should have been created
186
+ // One run record IS created with status 'ok'
186
187
  const runs = getScheduleRuns(id);
187
- expect(runs.length).toBe(0);
188
+ expect(runs.length).toBe(1);
189
+ expect(runs[0].status).toBe('ok');
190
+
191
+ // After firing, the schedule is disabled with nextRunAt=0
192
+ const afterSchedule = getSchedule(id);
193
+ expect(afterSchedule).not.toBeNull();
194
+ expect(afterSchedule!.enabled).toBe(false);
195
+ expect(afterSchedule!.nextRunAt).toBe(0);
196
+
197
+ // Second tick: the disabled schedule must NOT fire again
198
+ processedMessages.length = 0;
199
+ const scheduler2 = startScheduler(processMessage, () => {}, () => {});
200
+ await new Promise(resolve => setTimeout(resolve, 500));
201
+ scheduler2.stop();
202
+
203
+ expect(processedMessages).not.toContain('Final expired run');
204
+ // No additional runs
205
+ const runsAfter = getScheduleRuns(id);
206
+ expect(runsAfter.length).toBe(1);
188
207
  });
189
208
 
190
209
  test('existing cron schedule behavior is unchanged', async () => {
@@ -299,6 +318,88 @@ describe('scheduler RRULE execution', () => {
299
318
  expect(runs[0].status).toBe('ok');
300
319
  });
301
320
 
321
+ test('EXRULE schedule skips excluded occurrence and advances to next valid date', async () => {
322
+ // RRULE: every minute from a known dtstart
323
+ // EXRULE: every 2nd minute from the same dtstart (excludes offsets 0, 2, 4, ...)
324
+ //
325
+ // DTSTART is set to 59 minutes ago (floored to minute) so the first
326
+ // occurrence after "now" without EXRULE would be at offset 60 (even).
327
+ // With EXRULE active, offset 60 is excluded and the scheduler must
328
+ // advance to offset 61 (odd). Using 59 minutes (not 60) is critical:
329
+ // at 60 minutes the first post-now occurrence is offset 61 (odd), which
330
+ // would pass the parity check even if EXRULE were completely ignored.
331
+ //
332
+ // We mock Date.now() so the scheduler's internal clock matches the test
333
+ // baseline exactly, making the test fully deterministic regardless of
334
+ // when it runs.
335
+ const realNow = new Date();
336
+ const frozenNow = new Date(realNow);
337
+ frozenNow.setUTCSeconds(30);
338
+ frozenNow.setUTCMilliseconds(0);
339
+
340
+ const originalDateNow = Date.now;
341
+ Date.now = () => frozenNow.getTime();
342
+
343
+ try {
344
+ const pad = (n: number) => String(n).padStart(2, '0');
345
+ const pastDate = new Date(frozenNow.getTime() - 59 * 60_000);
346
+ pastDate.setUTCSeconds(0);
347
+ pastDate.setUTCMilliseconds(0);
348
+ const ds = `${pastDate.getUTCFullYear()}${pad(pastDate.getUTCMonth() + 1)}${pad(pastDate.getUTCDate())}T${pad(pastDate.getUTCHours())}${pad(pastDate.getUTCMinutes())}00Z`;
349
+
350
+ const expression = `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1\nEXRULE:FREQ=MINUTELY;INTERVAL=2`;
351
+
352
+ // Compute what the next occurrence would be WITHOUT EXRULE — this should
353
+ // be at an even offset that EXRULE must exclude.
354
+ const withoutExrule = `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1`;
355
+ const { computeNextRunAt } = await import('../schedule/recurrence-engine.js');
356
+ const nextWithoutExrule = computeNextRunAt(
357
+ { syntax: 'rrule', expression: withoutExrule },
358
+ frozenNow.getTime(),
359
+ );
360
+ const offsetWithout = Math.round((nextWithoutExrule - pastDate.getTime()) / 60_000);
361
+ // Sanity: the without-EXRULE occurrence must be even (would be excluded)
362
+ expect(offsetWithout % 2).toBe(0);
363
+
364
+ const schedule = createSchedule({
365
+ name: 'EXRULE scheduler test',
366
+ cronExpression: expression,
367
+ message: 'EXRULE scheduler fire',
368
+ syntax: 'rrule',
369
+ expression,
370
+ });
371
+
372
+ forceScheduleDue(schedule.id);
373
+
374
+ const processedMessages: string[] = [];
375
+ const processMessage = async (_conversationId: string, message: string) => {
376
+ processedMessages.push(message);
377
+ };
378
+
379
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
380
+ await new Promise(resolve => setTimeout(resolve, 500));
381
+ scheduler.stop();
382
+
383
+ // The schedule should have fired
384
+ expect(processedMessages).toContain('EXRULE scheduler fire');
385
+
386
+ const after = getSchedule(schedule.id);
387
+ expect(after).not.toBeNull();
388
+ expect(after!.lastRunAt).not.toBeNull();
389
+ expect(after!.nextRunAt).toBeGreaterThan(frozenNow.getTime() - 5000);
390
+
391
+ // nextRunAt must NOT equal the without-EXRULE occurrence (which is excluded)
392
+ expect(after!.nextRunAt).not.toBe(nextWithoutExrule);
393
+
394
+ // nextRunAt must land on an odd-minute offset from dtstart
395
+ const dtstartMs = pastDate.getTime();
396
+ const minuteOffset = Math.round((after!.nextRunAt - dtstartMs) / 60_000);
397
+ expect(minuteOffset % 2).toBe(1);
398
+ } finally {
399
+ Date.now = originalDateNow;
400
+ }
401
+ });
402
+
302
403
  test('RRULE schedule advances nextRunAt after firing', async () => {
303
404
  const rruleExpr = buildEveryMinuteRrule();
304
405
  const schedule = createSchedule({
@@ -322,8 +423,8 @@ describe('scheduler RRULE execution', () => {
322
423
  expect(after).not.toBeNull();
323
424
  // nextRunAt must have moved forward from the forced-due value
324
425
  expect(after!.nextRunAt).toBeGreaterThan(forcedDueAt);
325
- // It should be at or near the original computed value (within a few seconds tolerance)
326
- expect(Math.abs(after!.nextRunAt - originalNextRunAt)).toBeLessThan(5000);
426
+ // After claiming, MINUTELY recurrence advances nextRunAt by ~60s, so allow up to 65s tolerance
427
+ expect(Math.abs(after!.nextRunAt - originalNextRunAt)).toBeLessThan(65000);
327
428
  expect(after!.lastRunAt).not.toBeNull();
328
429
  });
329
430
  });
@@ -13,6 +13,7 @@ mock.module('../util/logger.js', () => ({
13
13
  }));
14
14
 
15
15
  mock.module('../util/platform.js', () => ({
16
+ getRootDir: () => testDir,
16
17
  getDataDir: () => testDir,
17
18
  }));
18
19
 
@@ -22,7 +23,7 @@ import { scanText } from '../security/secret-scanner.js';
22
23
  describe('secret-allowlist', () => {
23
24
  beforeEach(() => {
24
25
  testDir = join(tmpdir(), `vellum-allowlist-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
25
- mkdirSync(testDir, { recursive: true });
26
+ mkdirSync(join(testDir, 'protected'), { recursive: true });
26
27
  resetAllowlist();
27
28
  });
28
29
 
@@ -45,7 +46,7 @@ describe('secret-allowlist', () => {
45
46
  // -----------------------------------------------------------------------
46
47
  test('[experimental] suppresses exact values', () => {
47
48
  writeFileSync(
48
- join(testDir, 'secret-allowlist.json'),
49
+ join(testDir, 'protected', 'secret-allowlist.json'),
49
50
  JSON.stringify({ values: ['my-test-api-key-12345'] }),
50
51
  );
51
52
  expect(isAllowlisted('my-test-api-key-12345')).toBe(true);
@@ -54,7 +55,7 @@ describe('secret-allowlist', () => {
54
55
 
55
56
  test('[experimental] exact values are case-sensitive', () => {
56
57
  writeFileSync(
57
- join(testDir, 'secret-allowlist.json'),
58
+ join(testDir, 'protected', 'secret-allowlist.json'),
58
59
  JSON.stringify({ values: ['MyTestKey'] }),
59
60
  );
60
61
  expect(isAllowlisted('MyTestKey')).toBe(true);
@@ -66,7 +67,7 @@ describe('secret-allowlist', () => {
66
67
  // -----------------------------------------------------------------------
67
68
  test('[experimental] suppresses values matching a prefix', () => {
68
69
  writeFileSync(
69
- join(testDir, 'secret-allowlist.json'),
70
+ join(testDir, 'protected', 'secret-allowlist.json'),
70
71
  JSON.stringify({ prefixes: ['my-internal-'] }),
71
72
  );
72
73
  expect(isAllowlisted('my-internal-key-abc123')).toBe(true);
@@ -78,7 +79,7 @@ describe('secret-allowlist', () => {
78
79
  // -----------------------------------------------------------------------
79
80
  test('[experimental] suppresses values matching a regex pattern', () => {
80
81
  writeFileSync(
81
- join(testDir, 'secret-allowlist.json'),
82
+ join(testDir, 'protected', 'secret-allowlist.json'),
82
83
  JSON.stringify({ patterns: ['^ci-test-[a-z0-9]+$'] }),
83
84
  );
84
85
  expect(isAllowlisted('ci-test-abc123')).toBe(true);
@@ -87,7 +88,7 @@ describe('secret-allowlist', () => {
87
88
 
88
89
  test('[experimental] invalid regex is skipped without crashing', () => {
89
90
  writeFileSync(
90
- join(testDir, 'secret-allowlist.json'),
91
+ join(testDir, 'protected', 'secret-allowlist.json'),
91
92
  JSON.stringify({ patterns: ['[invalid', '^valid$'] }),
92
93
  );
93
94
  loadAllowlist();
@@ -102,7 +103,7 @@ describe('secret-allowlist', () => {
102
103
  // -----------------------------------------------------------------------
103
104
  test('[experimental] combines values, prefixes, and patterns', () => {
104
105
  writeFileSync(
105
- join(testDir, 'secret-allowlist.json'),
106
+ join(testDir, 'protected', 'secret-allowlist.json'),
106
107
  JSON.stringify({
107
108
  values: ['exact-match-value'],
108
109
  prefixes: ['test-prefix-'],
@@ -119,7 +120,7 @@ describe('secret-allowlist', () => {
119
120
  // Malformed file
120
121
  // -----------------------------------------------------------------------
121
122
  test('handles malformed JSON gracefully', () => {
122
- writeFileSync(join(testDir, 'secret-allowlist.json'), 'not json{{{');
123
+ writeFileSync(join(testDir, 'protected', 'secret-allowlist.json'), 'not json{{{');
123
124
  loadAllowlist();
124
125
  // Should not throw, just log warning
125
126
  expect(isAllowlisted('anything')).toBe(false);
@@ -127,7 +128,7 @@ describe('secret-allowlist', () => {
127
128
 
128
129
  test('handles non-array fields gracefully', () => {
129
130
  writeFileSync(
130
- join(testDir, 'secret-allowlist.json'),
131
+ join(testDir, 'protected', 'secret-allowlist.json'),
131
132
  JSON.stringify({ values: 'not-an-array', prefixes: 42 }),
132
133
  );
133
134
  loadAllowlist();
@@ -140,7 +141,7 @@ describe('secret-allowlist', () => {
140
141
  test('[experimental] allowlisted values are suppressed by scanText', () => {
141
142
  const awsKey = 'AKIAIOSFODNN7REALKEY';
142
143
  writeFileSync(
143
- join(testDir, 'secret-allowlist.json'),
144
+ join(testDir, 'protected', 'secret-allowlist.json'),
144
145
  JSON.stringify({ values: [awsKey] }),
145
146
  );
146
147
  resetAllowlist();
@@ -152,7 +153,7 @@ describe('secret-allowlist', () => {
152
153
 
153
154
  test('non-allowlisted values are still detected by scanText', () => {
154
155
  writeFileSync(
155
- join(testDir, 'secret-allowlist.json'),
156
+ join(testDir, 'protected', 'secret-allowlist.json'),
156
157
  JSON.stringify({ values: ['AKIAIOSFODNN7OTHERKE'] }),
157
158
  );
158
159
  resetAllowlist();
@@ -164,7 +165,7 @@ describe('secret-allowlist', () => {
164
165
 
165
166
  test('[experimental] prefix allowlist suppresses pattern matches', () => {
166
167
  writeFileSync(
167
- join(testDir, 'secret-allowlist.json'),
168
+ join(testDir, 'protected', 'secret-allowlist.json'),
168
169
  JSON.stringify({ prefixes: ['ghp_AAAA'] }),
169
170
  );
170
171
  resetAllowlist();
@@ -184,7 +185,7 @@ describe('secret-allowlist', () => {
184
185
 
185
186
  // Create the file — but fileChecked is cached, so it won't be seen
186
187
  writeFileSync(
187
- join(testDir, 'secret-allowlist.json'),
188
+ join(testDir, 'protected', 'secret-allowlist.json'),
188
189
  JSON.stringify({ values: ['test-key'] }),
189
190
  );
190
191
  expect(isAllowlisted('test-key')).toBe(false);
@@ -196,13 +197,13 @@ describe('secret-allowlist', () => {
196
197
 
197
198
  test('[experimental] retries loading when file was malformed on first call', () => {
198
199
  // First call with malformed JSON
199
- writeFileSync(join(testDir, 'secret-allowlist.json'), 'not json{{{');
200
+ writeFileSync(join(testDir, 'protected', 'secret-allowlist.json'), 'not json{{{');
200
201
  loadAllowlist();
201
202
  expect(isAllowlisted('test-key')).toBe(false);
202
203
 
203
204
  // Fix the file
204
205
  writeFileSync(
205
- join(testDir, 'secret-allowlist.json'),
206
+ join(testDir, 'protected', 'secret-allowlist.json'),
206
207
  JSON.stringify({ values: ['test-key'] }),
207
208
  );
208
209
 
@@ -215,7 +216,7 @@ describe('secret-allowlist', () => {
215
216
  // -----------------------------------------------------------------------
216
217
  test('[experimental] resetAllowlist clears cached state', () => {
217
218
  writeFileSync(
218
- join(testDir, 'secret-allowlist.json'),
219
+ join(testDir, 'protected', 'secret-allowlist.json'),
219
220
  JSON.stringify({ values: ['test-value'] }),
220
221
  );
221
222
  loadAllowlist();
@@ -223,7 +224,7 @@ describe('secret-allowlist', () => {
223
224
 
224
225
  // Reset and remove file — should no longer be allowlisted
225
226
  resetAllowlist();
226
- rmSync(join(testDir, 'secret-allowlist.json'));
227
+ rmSync(join(testDir, 'protected', 'secret-allowlist.json'));
227
228
  expect(isAllowlisted('test-value')).toBe(false);
228
229
  });
229
230
  });
@@ -12,6 +12,8 @@ const mockConfig = {
12
12
  mock.module('../config/loader.js', () => ({
13
13
  getConfig: () => mockConfig,
14
14
  loadConfig: () => mockConfig,
15
+ loadRawConfig: () => ({ secretDetection: { ...mockConfig.secretDetection } }),
16
+ saveRawConfig: () => {},
15
17
  invalidateConfigCache: () => {},
16
18
  }));
17
19
 
@@ -19,6 +21,7 @@ mock.module('../util/logger.js', () => ({
19
21
  getLogger: () => new Proxy({} as Record<string, unknown>, {
20
22
  get: () => () => {},
21
23
  }),
24
+ isDebug: () => false,
22
25
  }));
23
26
 
24
27
  const { checkIngressForSecrets } = await import('../security/secret-ingress.js');
@@ -27,6 +30,7 @@ const { checkIngressForSecrets } = await import('../security/secret-ingress.js')
27
30
  // These are well-known fake AWS/GitHub patterns used across the test suite.
28
31
  const AWS_KEY = ['AKIA', 'IOSFODNN7', 'REALKEY'].join('');
29
32
  const GH_TOKEN = ['ghp_', 'ABCDEFghijklMN01234567', '89abcdefghijkl'].join('');
33
+ const TG_TOKEN = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
30
34
 
31
35
  describe('secret ingress handler', () => {
32
36
  beforeEach(() => {
@@ -84,6 +88,13 @@ describe('secret ingress handler', () => {
84
88
  expect(result.detectedTypes.length).toBeGreaterThanOrEqual(2);
85
89
  });
86
90
 
91
+ test('blocks message containing a Telegram bot token', () => {
92
+ const result = checkIngressForSecrets(`Here is my bot token: ${TG_TOKEN}`);
93
+ expect(result.blocked).toBe(true);
94
+ expect(result.detectedTypes).toContain('Telegram Bot Token');
95
+ expect(result.userNotice).not.toContain(TG_TOKEN);
96
+ });
97
+
87
98
  test('empty content passes through', () => {
88
99
  const result = checkIngressForSecrets('');
89
100
  expect(result.blocked).toBe(false);
@@ -130,6 +130,49 @@ describe('Slack tokens', () => {
130
130
  });
131
131
  });
132
132
 
133
+ // ---------------------------------------------------------------------------
134
+ // Telegram
135
+ // ---------------------------------------------------------------------------
136
+ describe('Telegram bot tokens', () => {
137
+ // Build test token at runtime to avoid tripping pre-commit secret hook
138
+ const BOT_TOKEN = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
139
+
140
+ test('detects Telegram bot token', () => {
141
+ expectMatch(`token=${BOT_TOKEN}`, 'Telegram Bot Token');
142
+ });
143
+
144
+ test('detects bot token in surrounding text', () => {
145
+ expectMatch(`My bot token is ${BOT_TOKEN} please save it`, 'Telegram Bot Token');
146
+ });
147
+
148
+ test('detects bot token ending with hyphen', () => {
149
+ // ~1.5% of valid tokens end with '-'; trailing \b would miss these
150
+ const tokenEndingHyphen = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz01234567-'].join('');
151
+ expectMatch(`token=${tokenEndingHyphen}`, 'Telegram Bot Token');
152
+ });
153
+
154
+ test('does not flag short numeric:alpha strings', () => {
155
+ // Too few digits in bot ID (only 5)
156
+ const matches = scanText('12345:ABCDefGHIJklmnopQRSTuvwxyz012345678');
157
+ const telegram = matches.filter((m) => m.type === 'Telegram Bot Token');
158
+ expect(telegram).toHaveLength(0);
159
+ });
160
+
161
+ test('does not flag token with wrong secret length', () => {
162
+ // Secret part is only 10 chars (needs 35)
163
+ const matches = scanText('123456789:ABCDefGHIJ');
164
+ const telegram = matches.filter((m) => m.type === 'Telegram Bot Token');
165
+ expect(telegram).toHaveLength(0);
166
+ });
167
+
168
+ test('does not flag token with too-long secret part', () => {
169
+ // Secret part is 40 chars (needs exactly 35)
170
+ const matches = scanText('123456789:ABCDefGHIJklmnopQRSTuvwxyz0123456789AB');
171
+ const telegram = matches.filter((m) => m.type === 'Telegram Bot Token');
172
+ expect(telegram).toHaveLength(0);
173
+ });
174
+ });
175
+
133
176
  // ---------------------------------------------------------------------------
134
177
  // Anthropic
135
178
  // ---------------------------------------------------------------------------