@onmars/lunar-core 0.1.0

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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Scheduler — Cron-based job system for proactive AI behavior.
3
+ *
4
+ * Spec (ARCHITECTURE.md §16):
5
+ * - Polls SQLite jobs table every pollInterval (default 60s)
6
+ * - Three schedule types: cron (5-field), at (one-shot ISO-8601), every (interval)
7
+ * - Two payload types: query (agent), message (static)
8
+ * - Dispatches via Router.dispatchScheduled()
9
+ * - Jobs defined in config.yaml and/or created at runtime via slash commands
10
+ * - Timezone-aware (Intl.DateTimeFormat)
11
+ *
12
+ * Lifecycle:
13
+ * daemon.start() -> scheduler.start()
14
+ * daemon.stop() -> scheduler.stop()
15
+ */
16
+
17
+ import { type CronFields, nextOccurrence, parseCron } from './cron-parser'
18
+ import { log } from './logger'
19
+
20
+ // ════════════════════════════════════════════════════════════
21
+ // Types
22
+ // ════════════════════════════════════════════════════════════
23
+
24
+ export type Schedule =
25
+ | { kind: 'cron'; expr: string; tz?: string }
26
+ | { kind: 'at'; at: string }
27
+ | { kind: 'every'; intervalMs: number }
28
+
29
+ export type JobPayload = { kind: 'query'; prompt: string } | { kind: 'message'; text: string }
30
+
31
+ export interface Job {
32
+ id: string
33
+ name?: string
34
+ schedule: Schedule
35
+ payload: JobPayload
36
+ channel: string
37
+ moon?: string
38
+ enabled: boolean
39
+ oneShot: boolean
40
+ lastRun?: number
41
+ nextRun?: number
42
+ createdAt: number
43
+ }
44
+
45
+ export interface SchedulerConfig {
46
+ enabled: boolean
47
+ pollIntervalMs: number
48
+ timezone: string
49
+ jobs: Record<string, JobConfig>
50
+ }
51
+
52
+ export interface JobConfig {
53
+ schedule: Schedule
54
+ channel: string
55
+ payload: JobPayload
56
+ moon?: string
57
+ enabled?: boolean
58
+ oneShot?: boolean
59
+ }
60
+
61
+ /** Dispatch function signature — provided by the Router */
62
+ export type DispatchFn = (
63
+ channel: string,
64
+ payload: JobPayload,
65
+ options?: { moon?: string; jobId?: string; jobName?: string },
66
+ ) => Promise<void>
67
+
68
+ // ════════════════════════════════════════════════════════════
69
+ // JobStore — SQLite persistence layer
70
+ // ════════════════════════════════════════════════════════════
71
+
72
+ /**
73
+ * JobStore wraps the SQLite database for job CRUD operations.
74
+ * Uses the `jobs` table already created by SessionStore.
75
+ */
76
+ export class JobStore {
77
+ private db: import('bun:sqlite').Database
78
+
79
+ constructor(db: import('bun:sqlite').Database) {
80
+ this.db = db
81
+ this.ensureSchema()
82
+ }
83
+
84
+ private ensureSchema(): void {
85
+ // Add columns that may not exist in the v0.1 schema
86
+ // SQLite doesn't have IF NOT EXISTS for columns, so we check first
87
+ try {
88
+ this.db.exec(`
89
+ ALTER TABLE jobs ADD COLUMN channel TEXT NOT NULL DEFAULT '';
90
+ `)
91
+ } catch {
92
+ /* column already exists */
93
+ }
94
+
95
+ try {
96
+ this.db.exec(`
97
+ ALTER TABLE jobs ADD COLUMN moon TEXT;
98
+ `)
99
+ } catch {
100
+ /* column already exists */
101
+ }
102
+
103
+ try {
104
+ this.db.exec(`
105
+ ALTER TABLE jobs ADD COLUMN one_shot INTEGER NOT NULL DEFAULT 0;
106
+ `)
107
+ } catch {
108
+ /* column already exists */
109
+ }
110
+ }
111
+
112
+ /** Add a new job */
113
+ add(job: Job): void {
114
+ this.db
115
+ .query(`
116
+ INSERT OR REPLACE INTO jobs (id, name, schedule, payload, channel, moon, enabled, one_shot, last_run, next_run, created_at)
117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
118
+ `)
119
+ .run(
120
+ job.id,
121
+ job.name ?? null,
122
+ JSON.stringify(job.schedule),
123
+ JSON.stringify(job.payload),
124
+ job.channel,
125
+ job.moon ?? null,
126
+ job.enabled ? 1 : 0,
127
+ job.oneShot ? 1 : 0,
128
+ job.lastRun ?? null,
129
+ job.nextRun ?? null,
130
+ job.createdAt,
131
+ )
132
+ }
133
+
134
+ /** Get a job by ID or name */
135
+ get(idOrName: string): Job | null {
136
+ const row = this.db
137
+ .query('SELECT * FROM jobs WHERE id = ? OR name = ?')
138
+ .get(idOrName, idOrName) as JobRow | null
139
+ return row ? this.mapRow(row) : null
140
+ }
141
+
142
+ /** List all jobs (optionally only enabled) */
143
+ list(enabledOnly = false): Job[] {
144
+ const query = enabledOnly
145
+ ? 'SELECT * FROM jobs WHERE enabled = 1 ORDER BY next_run ASC'
146
+ : 'SELECT * FROM jobs ORDER BY created_at DESC'
147
+ const rows = this.db.query(query).all() as JobRow[]
148
+ return rows.map((r) => this.mapRow(r))
149
+ }
150
+
151
+ /** Get due jobs (next_run <= now and enabled) */
152
+ getDue(now: number): Job[] {
153
+ const rows = this.db
154
+ .query('SELECT * FROM jobs WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ?')
155
+ .all(now) as JobRow[]
156
+ return rows.map((r) => this.mapRow(r))
157
+ }
158
+
159
+ /** Update last_run and next_run */
160
+ updateRun(id: string, lastRun: number, nextRun: number | null): void {
161
+ this.db
162
+ .query('UPDATE jobs SET last_run = ?, next_run = ? WHERE id = ?')
163
+ .run(lastRun, nextRun, id)
164
+ }
165
+
166
+ /** Disable a job */
167
+ disable(id: string): void {
168
+ this.db.query('UPDATE jobs SET enabled = 0 WHERE id = ?').run(id)
169
+ }
170
+
171
+ /** Enable a job */
172
+ enable(id: string): void {
173
+ this.db.query('UPDATE jobs SET enabled = 1 WHERE id = ?').run(id)
174
+ }
175
+
176
+ /** Remove a job */
177
+ remove(id: string): boolean {
178
+ const result = this.db.query('DELETE FROM jobs WHERE id = ? OR name = ?').run(id, id)
179
+ return result.changes > 0
180
+ }
181
+
182
+ /** Map a database row to a Job object */
183
+ private mapRow(row: JobRow): Job {
184
+ return {
185
+ id: row.id,
186
+ name: row.name ?? undefined,
187
+ schedule: JSON.parse(row.schedule) as Schedule,
188
+ payload: JSON.parse(row.payload) as JobPayload,
189
+ channel: row.channel,
190
+ moon: row.moon ?? undefined,
191
+ enabled: row.enabled === 1,
192
+ oneShot: row.one_shot === 1,
193
+ lastRun: row.last_run ?? undefined,
194
+ nextRun: row.next_run ?? undefined,
195
+ createdAt: row.created_at,
196
+ }
197
+ }
198
+ }
199
+
200
+ interface JobRow {
201
+ id: string
202
+ name: string | null
203
+ schedule: string
204
+ payload: string
205
+ channel: string
206
+ moon: string | null
207
+ enabled: number
208
+ one_shot: number
209
+ last_run: number | null
210
+ next_run: number | null
211
+ created_at: number
212
+ }
213
+
214
+ // ════════════════════════════════════════════════════════════
215
+ // Scheduler — Poll loop + dispatch
216
+ // ════════════════════════════════════════════════════════════
217
+
218
+ export class Scheduler {
219
+ private store: JobStore
220
+ private dispatch: DispatchFn
221
+ private config: SchedulerConfig
222
+ private intervalHandle?: ReturnType<typeof setInterval>
223
+ private running = false
224
+ /** Cache parsed cron fields to avoid re-parsing every tick */
225
+ private cronCache = new Map<string, CronFields>()
226
+
227
+ constructor(store: JobStore, dispatch: DispatchFn, config: SchedulerConfig) {
228
+ this.store = store
229
+ this.dispatch = dispatch
230
+ this.config = config
231
+ }
232
+
233
+ /**
234
+ * Start the scheduler.
235
+ * 1. Sync config-defined jobs to the database
236
+ * 2. Compute next_run for all enabled jobs
237
+ * 3. Start the poll interval
238
+ */
239
+ async start(): Promise<void> {
240
+ if (this.running) return
241
+
242
+ // Sync config jobs -> database
243
+ this.syncConfigJobs()
244
+
245
+ // Compute initial next_run for all enabled jobs
246
+ this.computeAllNextRuns()
247
+
248
+ // Start poll loop
249
+ this.running = true
250
+ this.intervalHandle = setInterval(() => {
251
+ this.tick().catch((err) => {
252
+ log.error({ err }, 'Scheduler tick error')
253
+ })
254
+ }, this.config.pollIntervalMs)
255
+
256
+ const jobs = this.store.list(true)
257
+ log.info(
258
+ { pollMs: this.config.pollIntervalMs, tz: this.config.timezone, enabledJobs: jobs.length },
259
+ 'Scheduler started',
260
+ )
261
+
262
+ // Log next runs
263
+ for (const job of jobs) {
264
+ if (job.nextRun) {
265
+ log.debug(
266
+ { name: job.name ?? job.id, nextRun: new Date(job.nextRun * 1000).toISOString() },
267
+ 'Job scheduled',
268
+ )
269
+ }
270
+ }
271
+ }
272
+
273
+ /** Stop the scheduler */
274
+ async stop(): Promise<void> {
275
+ if (!this.running) return
276
+
277
+ this.running = false
278
+ if (this.intervalHandle) {
279
+ clearInterval(this.intervalHandle)
280
+ this.intervalHandle = undefined
281
+ }
282
+
283
+ log.info('Scheduler stopped')
284
+ }
285
+
286
+ /** Get the job store for external access (slash commands, etc.) */
287
+ getStore(): JobStore {
288
+ return this.store
289
+ }
290
+
291
+ // ────────────────────────────────────────────────────────
292
+
293
+ /** One poll cycle: find and execute due jobs */
294
+ async tick(): Promise<void> {
295
+ const nowSec = Math.floor(Date.now() / 1000)
296
+ const dueJobs = this.store.getDue(nowSec)
297
+
298
+ if (dueJobs.length === 0) return
299
+
300
+ log.debug({ count: dueJobs.length }, 'Scheduler: due jobs found')
301
+
302
+ for (const job of dueJobs) {
303
+ try {
304
+ log.info(
305
+ { id: job.id, name: job.name, channel: job.channel, kind: job.payload.kind },
306
+ 'Executing scheduled job',
307
+ )
308
+
309
+ await this.dispatch(job.channel, job.payload, {
310
+ moon: job.moon,
311
+ jobId: job.id,
312
+ jobName: job.name,
313
+ })
314
+
315
+ // Update last_run and compute next_run
316
+ const nextRun = this.computeNextRun(job.schedule, nowSec)
317
+ this.store.updateRun(job.id, nowSec, nextRun)
318
+
319
+ // Auto-disable one-shot jobs
320
+ if (job.oneShot) {
321
+ this.store.disable(job.id)
322
+ log.info({ id: job.id, name: job.name }, 'One-shot job disabled after execution')
323
+ }
324
+ } catch (err) {
325
+ log.error({ err, id: job.id, name: job.name }, 'Failed to execute scheduled job')
326
+ // Don't re-throw — continue with other jobs
327
+ // Still update last_run to avoid retrying immediately
328
+ const nextRun = this.computeNextRun(job.schedule, nowSec)
329
+ this.store.updateRun(job.id, nowSec, nextRun)
330
+ }
331
+ }
332
+ }
333
+
334
+ // ────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Sync config-defined jobs to the database.
338
+ * Config is the source of truth for static jobs.
339
+ * Runtime-created jobs (no matching config name) are preserved.
340
+ */
341
+ private syncConfigJobs(): void {
342
+ const existingJobs = this.store.list()
343
+ const existingByName = new Map(existingJobs.filter((j) => j.name).map((j) => [j.name!, j]))
344
+
345
+ for (const [name, config] of Object.entries(this.config.jobs)) {
346
+ const existing = existingByName.get(name)
347
+
348
+ const job: Job = {
349
+ id: existing?.id ?? crypto.randomUUID(),
350
+ name,
351
+ schedule: config.schedule,
352
+ payload: config.payload,
353
+ channel: config.channel,
354
+ moon: config.moon,
355
+ enabled: config.enabled !== false,
356
+ oneShot: config.oneShot === true,
357
+ lastRun: existing?.lastRun,
358
+ nextRun: existing?.nextRun,
359
+ createdAt: existing?.createdAt ?? Math.floor(Date.now() / 1000),
360
+ }
361
+
362
+ this.store.add(job)
363
+ log.debug({ name, id: job.id }, 'Config job synced')
364
+ }
365
+ }
366
+
367
+ /** Compute next_run for all enabled jobs that don't have one */
368
+ private computeAllNextRuns(): void {
369
+ const jobs = this.store.list(true)
370
+ const nowSec = Math.floor(Date.now() / 1000)
371
+
372
+ for (const job of jobs) {
373
+ if (!job.nextRun || job.nextRun <= nowSec) {
374
+ const nextRun = this.computeNextRun(job.schedule, nowSec)
375
+ if (nextRun !== null) {
376
+ this.store.updateRun(job.id, job.lastRun ?? 0, nextRun)
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Compute the next run time for a schedule.
384
+ * @returns Unix timestamp (seconds) or null if no next run
385
+ */
386
+ computeNextRun(schedule: Schedule, afterSec: number): number | null {
387
+ const afterMs = afterSec * 1000
388
+
389
+ switch (schedule.kind) {
390
+ case 'cron': {
391
+ const tz = schedule.tz ?? this.config.timezone
392
+ // Use cached parsed fields
393
+ const cacheKey = `${schedule.expr}|${tz}`
394
+ let fields = this.cronCache.get(cacheKey)
395
+ if (!fields) {
396
+ fields = parseCron(schedule.expr)
397
+ this.cronCache.set(cacheKey, fields)
398
+ }
399
+ const next = nextOccurrence(fields, afterMs, tz)
400
+ return next ? Math.floor(next.getTime() / 1000) : null
401
+ }
402
+
403
+ case 'at': {
404
+ const atMs = new Date(schedule.at).getTime()
405
+ if (Number.isNaN(atMs)) {
406
+ log.warn({ at: schedule.at }, 'Invalid "at" timestamp')
407
+ return null
408
+ }
409
+ // Only fire if in the future
410
+ return atMs > afterMs ? Math.floor(atMs / 1000) : null
411
+ }
412
+
413
+ case 'every': {
414
+ return afterSec + Math.floor(schedule.intervalMs / 1000)
415
+ }
416
+
417
+ default:
418
+ log.warn({ schedule }, 'Unknown schedule kind')
419
+ return null
420
+ }
421
+ }
422
+ }
@@ -0,0 +1,259 @@
1
+ import type { InputSanitizationConfig, SecurityConfig } from './config-loader'
2
+ import { log } from './logger'
3
+
4
+ /**
5
+ * Default regex patterns that are ALWAYS active for output redaction.
6
+ * User patterns from config.yaml are added on top of these.
7
+ */
8
+ const DEFAULT_REDACT_PATTERNS = [
9
+ /sk-[a-zA-Z0-9]{20,}/g, // OpenAI API keys
10
+ /ghp_[a-zA-Z0-9]{36}/g, // GitHub PATs
11
+ /github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained PATs
12
+ /xox[bpas]-[a-zA-Z0-9-]+/g, // Slack tokens
13
+ /\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/g, // Discord bot tokens
14
+ /ghu_[a-zA-Z0-9]{36}/g, // GitHub user tokens
15
+ /ghs_[a-zA-Z0-9]{36}/g, // GitHub server tokens
16
+ /AKIA[0-9A-Z]{16}/g, // AWS access keys
17
+ /AIza[0-9A-Za-z_-]{35}/g, // Google API keys
18
+ /sk_live_[a-zA-Z0-9]{24,}/g, // Stripe live keys
19
+ ]
20
+
21
+ /**
22
+ * Build a safe environment for the agent subprocess.
23
+ *
24
+ * Strategy: ALLOWLIST (default deny).
25
+ * Only variables in envDefaults + envPassthrough are included.
26
+ * If envPassthroughAll is true, passes everything (dangerous).
27
+ */
28
+ export function buildSafeEnv(
29
+ processEnv: Record<string, string | undefined>,
30
+ security: SecurityConfig,
31
+ extraEnv?: Record<string, string>,
32
+ ): Record<string, string> {
33
+ // Dangerous mode: pass everything
34
+ if (security.envPassthroughAll) {
35
+ log.warn('envPassthroughAll is enabled — ALL env vars will be passed to agent subprocess')
36
+ const env: Record<string, string> = {}
37
+ for (const [key, value] of Object.entries(processEnv)) {
38
+ if (value !== undefined) env[key] = value
39
+ }
40
+ if (extraEnv) Object.assign(env, extraEnv)
41
+ return env
42
+ }
43
+
44
+ // Build allowlist from defaults + user passthrough
45
+ const allowed = new Set([...security.envDefaults, ...security.envPassthrough])
46
+
47
+ const env: Record<string, string> = {}
48
+ for (const key of allowed) {
49
+ const value = processEnv[key]
50
+ if (value !== undefined) {
51
+ env[key] = value
52
+ }
53
+ }
54
+
55
+ // Apply programmatic overrides (these always take priority)
56
+ if (extraEnv) {
57
+ Object.assign(env, extraEnv)
58
+ }
59
+
60
+ log.debug(
61
+ {
62
+ allowed: allowed.size,
63
+ resolved: Object.keys(env).length,
64
+ keys: Object.keys(env).sort(),
65
+ },
66
+ 'Agent env built (allowlist)',
67
+ )
68
+
69
+ return env
70
+ }
71
+
72
+ /**
73
+ * Create a reusable redaction function from security config.
74
+ * Combines default patterns with user-configured patterns.
75
+ */
76
+ export function createRedactor(security: SecurityConfig): (text: string) => string {
77
+ // Compile user patterns
78
+ const userPatterns: RegExp[] = []
79
+ for (const pattern of security.outputRedactPatterns) {
80
+ try {
81
+ userPatterns.push(new RegExp(pattern, 'g'))
82
+ } catch (error) {
83
+ log.warn({ pattern, err: error }, 'Invalid redaction regex pattern, skipping')
84
+ }
85
+ }
86
+
87
+ const allPatterns = [...DEFAULT_REDACT_PATTERNS, ...userPatterns]
88
+
89
+ log.debug(
90
+ { defaultPatterns: DEFAULT_REDACT_PATTERNS.length, userPatterns: userPatterns.length },
91
+ 'Output redactor initialized',
92
+ )
93
+
94
+ return (text: string): string => {
95
+ let redacted = text
96
+ for (const pattern of allPatterns) {
97
+ // Reset lastIndex for global regexes
98
+ pattern.lastIndex = 0
99
+ redacted = redacted.replace(pattern, '[REDACTED]')
100
+ }
101
+ return redacted
102
+ }
103
+ }
104
+
105
+ // ════════════════════════════════════════════════════════════
106
+ // Input Sanitization — Defense against prompt injection
107
+ // ════════════════════════════════════════════════════════════
108
+
109
+ /**
110
+ * Tier 1 — Markers to STRIP silently from input.
111
+ * These are system-prompt-like markers that should never appear in user messages.
112
+ */
113
+ const STRIP_PATTERNS: RegExp[] = [
114
+ // XML-style system markers
115
+ /<\/?system>/gi,
116
+ /<\/?instructions>/gi,
117
+ /<\/?prompt>/gi,
118
+ /<\/?assistant>/gi,
119
+ /<\/?human>/gi,
120
+
121
+ // Bracket-style markers
122
+ /\[SYSTEM\]/gi,
123
+ /\[INST\]/gi,
124
+ /\[\/INST\]/gi,
125
+ /\[INSTRUCTIONS?\]/gi,
126
+
127
+ // Separator-style injection attempts
128
+ /^-{3,}\s*system\s*-{3,}$/gim,
129
+ /^={3,}\s*system\s*={3,}$/gim,
130
+
131
+ // "Ignore previous" family — the most common prompt injection
132
+ /ignore (?:all )?(?:previous|above|prior) (?:instructions?|prompts?|rules?)/gi,
133
+ /disregard (?:all )?(?:previous|above|prior) (?:instructions?|prompts?|rules?)/gi,
134
+ /forget (?:all )?(?:previous|above) (?:instructions?|context)/gi,
135
+ ]
136
+
137
+ /**
138
+ * Tier 2 — Patterns to DETECT and LOG (not stripped).
139
+ * These could be malicious or could be legitimate use.
140
+ * We log them for review but don't alter the message.
141
+ */
142
+ const SUSPICIOUS_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
143
+ // Role override attempts
144
+ { pattern: /you are now (?:a |an |the )/gi, label: 'role-override' },
145
+ { pattern: /from now on,? (?:you |your )/gi, label: 'behavior-override' },
146
+ { pattern: /new instructions?:/gi, label: 'instruction-inject' },
147
+
148
+ // System prompt extraction
149
+ {
150
+ pattern:
151
+ /(?:reveal|show|tell me|print|output|display|repeat) (?:your |the )?(?:system ?prompt|instructions|rules|secrets|initial prompt)/gi,
152
+ label: 'extraction-attempt',
153
+ },
154
+ {
155
+ pattern: /what (?:are|is|were) your (?:system ?prompt|instructions|rules|original prompt)/gi,
156
+ label: 'extraction-attempt',
157
+ },
158
+
159
+ // Encoding evasion
160
+ {
161
+ pattern:
162
+ /(?:encode|decode|convert) (?:this |it )?(?:in |to |as )?(?:base64|rot13|hex|binary)/gi,
163
+ label: 'encoding-evasion',
164
+ },
165
+
166
+ // Zero-width characters (can hide instructions)
167
+ { pattern: /\u200b|\u200c|\u200d|\ufeff/g, label: 'zero-width-chars' },
168
+ ]
169
+
170
+ /**
171
+ * Result of input sanitization.
172
+ */
173
+ export interface SanitizationResult {
174
+ /** Cleaned text */
175
+ text: string
176
+ /** Number of strip operations performed */
177
+ stripped: number
178
+ /** Labels of suspicious patterns detected */
179
+ suspicious: string[]
180
+ /** Whether any sanitization was applied */
181
+ wasSanitized: boolean
182
+ }
183
+
184
+ /**
185
+ * Sanitize user input against prompt injection attempts.
186
+ *
187
+ * Two tiers:
188
+ * 1. STRIP — remove known system-prompt markers silently
189
+ * 2. DETECT — log suspicious patterns without modifying text
190
+ *
191
+ * This is defense-in-depth, not a complete solution.
192
+ * Real protection comes from env isolation (L1 security).
193
+ *
194
+ * @param text - Raw user input
195
+ * @param config - Sanitization config from security section
196
+ * @returns SanitizationResult with cleaned text and detection info
197
+ */
198
+ export function sanitizeInput(text: string, config: InputSanitizationConfig): SanitizationResult {
199
+ if (!config.enabled) {
200
+ return { text, stripped: 0, suspicious: [], wasSanitized: false }
201
+ }
202
+
203
+ let cleaned = text
204
+ let stripped = 0
205
+
206
+ // Tier 1: Strip markers
207
+ if (config.stripMarkers) {
208
+ for (const pattern of STRIP_PATTERNS) {
209
+ pattern.lastIndex = 0
210
+ const before = cleaned
211
+ cleaned = cleaned.replace(pattern, '')
212
+ if (cleaned !== before) stripped++
213
+ }
214
+
215
+ // User-defined custom patterns
216
+ for (const patternStr of config.customPatterns) {
217
+ try {
218
+ const regex = new RegExp(patternStr, 'gi')
219
+ const before = cleaned
220
+ cleaned = cleaned.replace(regex, '')
221
+ if (cleaned !== before) stripped++
222
+ } catch {
223
+ // Invalid regex — skip silently (already warned at config load)
224
+ }
225
+ }
226
+
227
+ // Clean up leftover whitespace from stripping
228
+ if (stripped > 0) {
229
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim()
230
+ }
231
+ }
232
+
233
+ // Tier 2: Detect suspicious patterns (on original text, not cleaned)
234
+ const suspicious: string[] = []
235
+ if (config.logSuspicious) {
236
+ for (const { pattern, label } of SUSPICIOUS_PATTERNS) {
237
+ pattern.lastIndex = 0
238
+ if (pattern.test(text)) {
239
+ if (!suspicious.includes(label)) {
240
+ suspicious.push(label)
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ const wasSanitized = stripped > 0 || suspicious.length > 0
247
+
248
+ return { text: cleaned, stripped, suspicious, wasSanitized }
249
+ }
250
+
251
+ /**
252
+ * Create a reusable sanitizer function from config.
253
+ * Analogous to createRedactor() for output — this is for input.
254
+ */
255
+ export function createSanitizer(
256
+ config: InputSanitizationConfig,
257
+ ): (text: string) => SanitizationResult {
258
+ return (text: string) => sanitizeInput(text, config)
259
+ }