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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- 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('
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
scheduler1.stop();
|
|
181
182
|
|
|
182
|
-
// The
|
|
183
|
-
expect(processedMessages).
|
|
183
|
+
// The message IS delivered once
|
|
184
|
+
expect(processedMessages).toContain('Final expired run');
|
|
184
185
|
|
|
185
|
-
//
|
|
186
|
+
// One run record IS created with status 'ok'
|
|
186
187
|
const runs = getScheduleRuns(id);
|
|
187
|
-
expect(runs.length).toBe(
|
|
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
|
-
//
|
|
326
|
-
expect(Math.abs(after!.nextRunAt - originalNextRunAt)).toBeLessThan(
|
|
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
|
// ---------------------------------------------------------------------------
|