smolerclaw 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 (73) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/release.yml +67 -0
  3. package/bun.lock +33 -0
  4. package/dist/index.js +321 -0
  5. package/dist/tinyclaw.exe +0 -0
  6. package/install.ps1 +119 -0
  7. package/package.json +25 -0
  8. package/skills/business.md +77 -0
  9. package/skills/default.md +77 -0
  10. package/src/ansi.ts +164 -0
  11. package/src/approval.ts +74 -0
  12. package/src/auth.ts +125 -0
  13. package/src/briefing.ts +52 -0
  14. package/src/claude.ts +267 -0
  15. package/src/cli.ts +137 -0
  16. package/src/clipboard.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/context-window.ts +190 -0
  19. package/src/context.ts +125 -0
  20. package/src/decisions.ts +122 -0
  21. package/src/email.ts +123 -0
  22. package/src/errors.ts +78 -0
  23. package/src/export.ts +82 -0
  24. package/src/finance.ts +148 -0
  25. package/src/git.ts +62 -0
  26. package/src/history.ts +100 -0
  27. package/src/images.ts +68 -0
  28. package/src/index.ts +1431 -0
  29. package/src/investigate.ts +415 -0
  30. package/src/markdown.ts +125 -0
  31. package/src/memos.ts +191 -0
  32. package/src/models.ts +94 -0
  33. package/src/monitor.ts +169 -0
  34. package/src/morning.ts +108 -0
  35. package/src/news.ts +329 -0
  36. package/src/openai-provider.ts +127 -0
  37. package/src/people.ts +472 -0
  38. package/src/personas.ts +99 -0
  39. package/src/platform.ts +84 -0
  40. package/src/plugins.ts +125 -0
  41. package/src/pomodoro.ts +169 -0
  42. package/src/providers.ts +70 -0
  43. package/src/retry.ts +108 -0
  44. package/src/session.ts +128 -0
  45. package/src/skills.ts +102 -0
  46. package/src/tasks.ts +418 -0
  47. package/src/tokens.ts +102 -0
  48. package/src/tool-safety.ts +100 -0
  49. package/src/tools.ts +1479 -0
  50. package/src/tui.ts +693 -0
  51. package/src/types.ts +55 -0
  52. package/src/undo.ts +83 -0
  53. package/src/windows.ts +299 -0
  54. package/src/workflows.ts +197 -0
  55. package/tests/ansi.test.ts +58 -0
  56. package/tests/approval.test.ts +43 -0
  57. package/tests/briefing.test.ts +10 -0
  58. package/tests/cli.test.ts +53 -0
  59. package/tests/context-window.test.ts +83 -0
  60. package/tests/images.test.ts +28 -0
  61. package/tests/memos.test.ts +116 -0
  62. package/tests/models.test.ts +34 -0
  63. package/tests/news.test.ts +13 -0
  64. package/tests/path-guard.test.ts +37 -0
  65. package/tests/people.test.ts +204 -0
  66. package/tests/skills.test.ts +35 -0
  67. package/tests/ssrf.test.ts +80 -0
  68. package/tests/tasks.test.ts +152 -0
  69. package/tests/tokens.test.ts +44 -0
  70. package/tests/tool-safety.test.ts +55 -0
  71. package/tests/windows-security.test.ts +59 -0
  72. package/tests/windows.test.ts +20 -0
  73. package/tsconfig.json +19 -0
package/src/tasks.ts ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Task/reminder system with scheduled Windows notifications.
3
+ * Tasks are stored as JSON in the data directory.
4
+ * A background timer checks every 30s for due tasks and fires
5
+ * Windows toast notifications via PowerShell.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { IS_WINDOWS } from './platform'
11
+
12
+ // ─── Types ──────────────────────────────────────────────────
13
+
14
+ export interface Task {
15
+ id: string
16
+ title: string
17
+ dueAt: string | null // ISO 8601 datetime, null = no reminder
18
+ createdAt: string // ISO 8601 datetime
19
+ done: boolean
20
+ notified: boolean // whether the notification was already sent
21
+ }
22
+
23
+ // ─── Storage ────────────────────────────────────────────────
24
+
25
+ let _dataDir = ''
26
+ let _tasks: Task[] = []
27
+ let _checkTimer: ReturnType<typeof setInterval> | null = null
28
+ let _onNotify: ((task: Task) => void) | null = null
29
+
30
+ const TASKS_FILE = () => join(_dataDir, 'tasks.json')
31
+
32
+ function save(): void {
33
+ writeFileSync(TASKS_FILE(), JSON.stringify(_tasks, null, 2))
34
+ }
35
+
36
+ function load(): void {
37
+ const file = TASKS_FILE()
38
+ if (!existsSync(file)) {
39
+ _tasks = []
40
+ return
41
+ }
42
+ try {
43
+ _tasks = JSON.parse(readFileSync(file, 'utf-8'))
44
+ } catch {
45
+ _tasks = []
46
+ }
47
+ }
48
+
49
+ // ─── Public API ─────────────────────────────────────────────
50
+
51
+ /**
52
+ * Initialize the task system. Must be called once at startup.
53
+ * @param dataDir Directory to store tasks.json
54
+ * @param onNotify Callback when a task notification fires
55
+ */
56
+ export function initTasks(dataDir: string, onNotify: (task: Task) => void): void {
57
+ _dataDir = dataDir
58
+ _onNotify = onNotify
59
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
60
+ load()
61
+
62
+ // Start background checker (every 30 seconds) for in-process notifications
63
+ if (_checkTimer) clearInterval(_checkTimer)
64
+ _checkTimer = setInterval(checkDueTasks, 30_000)
65
+
66
+ // Sync pending reminders with Windows Task Scheduler so they fire
67
+ // even if smolerclaw is not running
68
+ syncScheduledTasks().catch(() => {})
69
+ }
70
+
71
+ /**
72
+ * Stop the background timer (call on exit).
73
+ */
74
+ export function stopTasks(): void {
75
+ if (_checkTimer) {
76
+ clearInterval(_checkTimer)
77
+ _checkTimer = null
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Add a new task with optional due time.
83
+ */
84
+ export function addTask(title: string, dueAt?: Date): Task {
85
+ const task: Task = {
86
+ id: generateId(),
87
+ title: title.trim(),
88
+ dueAt: dueAt ? dueAt.toISOString() : null,
89
+ createdAt: new Date().toISOString(),
90
+ done: false,
91
+ notified: false,
92
+ }
93
+ _tasks = [..._tasks, task]
94
+ save()
95
+
96
+ // Schedule a Windows Task Scheduler job so the reminder fires
97
+ // even if smolerclaw is not running
98
+ if (dueAt && IS_WINDOWS) {
99
+ scheduleWindowsTask(task).catch(() => {})
100
+ }
101
+
102
+ return task
103
+ }
104
+
105
+ /**
106
+ * Mark a task as done by ID or partial title match.
107
+ */
108
+ export function completeTask(idOrTitle: string): Task | null {
109
+ const lower = idOrTitle.toLowerCase()
110
+ const task = _tasks.find(
111
+ (t) => t.id === idOrTitle || t.title.toLowerCase().includes(lower),
112
+ )
113
+ if (!task || task.done) return null
114
+
115
+ _tasks = _tasks.map((t) =>
116
+ t.id === task.id ? { ...t, done: true } : t,
117
+ )
118
+ save()
119
+
120
+ // Remove the scheduled Windows task
121
+ if (task.dueAt && IS_WINDOWS) {
122
+ removeWindowsTask(task.id).catch(() => {})
123
+ }
124
+
125
+ return _tasks.find((t) => t.id === task.id) || null
126
+ }
127
+
128
+ /**
129
+ * Remove a task by ID or partial title.
130
+ */
131
+ export function removeTask(idOrTitle: string): boolean {
132
+ const lower = idOrTitle.toLowerCase()
133
+ const idx = _tasks.findIndex(
134
+ (t) => t.id === idOrTitle || t.title.toLowerCase().includes(lower),
135
+ )
136
+ if (idx === -1) return false
137
+
138
+ const task = _tasks[idx]
139
+ _tasks = [..._tasks.slice(0, idx), ..._tasks.slice(idx + 1)]
140
+ save()
141
+
142
+ // Remove the scheduled Windows task
143
+ if (task.dueAt && IS_WINDOWS) {
144
+ removeWindowsTask(task.id).catch(() => {})
145
+ }
146
+
147
+ return true
148
+ }
149
+
150
+ /**
151
+ * List all tasks, optionally filtering by done status.
152
+ */
153
+ export function listTasks(showDone = false): Task[] {
154
+ return showDone ? [..._tasks] : _tasks.filter((t) => !t.done)
155
+ }
156
+
157
+ /**
158
+ * Format tasks for display.
159
+ */
160
+ export function formatTaskList(tasks: Task[]): string {
161
+ if (tasks.length === 0) return 'Nenhuma tarefa pendente.'
162
+
163
+ const lines = tasks.map((t) => {
164
+ const status = t.done ? '[x]' : '[ ]'
165
+ const due = t.dueAt ? ` (${formatDueTime(t.dueAt)})` : ''
166
+ return ` ${status} ${t.title}${due} [${t.id}]`
167
+ })
168
+
169
+ return `Tarefas (${tasks.length}):\n${lines.join('\n')}`
170
+ }
171
+
172
+ /**
173
+ * Parse a natural-language time reference into a Date.
174
+ * Supports: "18h", "18:30", "14h30", "amanha 9h", "em 30 minutos"
175
+ */
176
+ export function parseTime(input: string): Date | null {
177
+ const now = new Date()
178
+ const text = input.toLowerCase().trim()
179
+
180
+ // "em X minutos" / "em X horas"
181
+ const inMatch = text.match(/em\s+(\d+)\s*(min|minutos?|h|horas?)/)
182
+ if (inMatch) {
183
+ const amount = parseInt(inMatch[1])
184
+ const unit = inMatch[2].startsWith('h') ? 'hours' : 'minutes'
185
+ const result = new Date(now)
186
+ if (unit === 'hours') {
187
+ result.setHours(result.getHours() + amount)
188
+ } else {
189
+ result.setMinutes(result.getMinutes() + amount)
190
+ }
191
+ return result
192
+ }
193
+
194
+ // Check for "amanha" prefix
195
+ let targetDate = new Date(now)
196
+ if (text.includes('amanha') || text.includes('amanhã')) {
197
+ targetDate.setDate(targetDate.getDate() + 1)
198
+ }
199
+
200
+ // "18h", "18h30", "18:30", "9h", "09:00"
201
+ const timeMatch = text.match(/(\d{1,2})\s*[h:]\s*(\d{2})?/)
202
+ if (timeMatch) {
203
+ const hours = parseInt(timeMatch[1])
204
+ const minutes = parseInt(timeMatch[2] || '0')
205
+ if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
206
+ targetDate.setHours(hours, minutes, 0, 0)
207
+
208
+ // If the time is already past today (and no "amanha"), set to tomorrow
209
+ if (targetDate <= now && !text.includes('amanha') && !text.includes('amanhã')) {
210
+ targetDate.setDate(targetDate.getDate() + 1)
211
+ }
212
+
213
+ return targetDate
214
+ }
215
+ }
216
+
217
+ return null
218
+ }
219
+
220
+ // ─── Background Checker ─────────────────────────────────────
221
+
222
+ function checkDueTasks(): void {
223
+ const now = new Date()
224
+ let changed = false
225
+
226
+ for (const task of _tasks) {
227
+ if (task.done || task.notified || !task.dueAt) continue
228
+
229
+ const due = new Date(task.dueAt)
230
+ if (isNaN(due.getTime())) continue
231
+
232
+ // Fire if due time has passed (within the last 5 minutes)
233
+ const diffMs = now.getTime() - due.getTime()
234
+ if (diffMs >= 0 && diffMs < 5 * 60_000) {
235
+ // Mark as notified
236
+ _tasks = _tasks.map((t) =>
237
+ t.id === task.id ? { ...t, notified: true } : t,
238
+ )
239
+ changed = true
240
+
241
+ // Fire Windows toast notification
242
+ fireNotification(task)
243
+
244
+ // Call the callback for TUI display
245
+ _onNotify?.(task)
246
+ }
247
+ }
248
+
249
+ if (changed) save()
250
+ }
251
+
252
+ /**
253
+ * Fire a Windows toast notification for a task.
254
+ * Uses PowerShell's BurntToast or built-in toast via .NET.
255
+ */
256
+ async function fireNotification(task: Task): Promise<void> {
257
+ if (!IS_WINDOWS) return
258
+
259
+ // Use .NET toast notification (works without external modules)
260
+ const title = task.title.replace(/'/g, "''")
261
+ const cmd = [
262
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
263
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null',
264
+ `$template = '<toast><visual><binding template="ToastText02"><text id="1">smolerclaw - Lembrete</text><text id="2">${title}</text></binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>'`,
265
+ '$xml = New-Object Windows.Data.Xml.Dom.XmlDocument',
266
+ '$xml.LoadXml($template)',
267
+ `$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)`,
268
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('smolerclaw').Show($toast)`,
269
+ ].join('; ')
270
+
271
+ try {
272
+ const proc = Bun.spawn(
273
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
274
+ { stdout: 'pipe', stderr: 'pipe' },
275
+ )
276
+ const timer = setTimeout(() => proc.kill(), 10_000)
277
+ await Promise.all([
278
+ new Response(proc.stdout).text(),
279
+ new Response(proc.stderr).text(),
280
+ ])
281
+ await proc.exited
282
+ clearTimeout(timer)
283
+ } catch {
284
+ // Best effort — notification failure should not crash
285
+ }
286
+ }
287
+
288
+ // ─── Windows Task Scheduler Integration ─────────────────────
289
+
290
+ const TASK_PREFIX = 'smolerclaw-reminder-'
291
+
292
+ /**
293
+ * Create a Windows Scheduled Task that fires a toast notification at the due time.
294
+ * Uses schtasks.exe — works without admin rights for the current user.
295
+ */
296
+ async function scheduleWindowsTask(task: Task): Promise<void> {
297
+ if (!task.dueAt) return
298
+
299
+ const due = new Date(task.dueAt)
300
+ if (isNaN(due.getTime()) || due.getTime() <= Date.now()) return
301
+
302
+ const taskName = `${TASK_PREFIX}${task.id}`
303
+
304
+ // Format date/time for schtasks: MM/DD/YYYY and HH:MM
305
+ const startDate = [
306
+ String(due.getMonth() + 1).padStart(2, '0'),
307
+ String(due.getDate()).padStart(2, '0'),
308
+ String(due.getFullYear()),
309
+ ].join('/')
310
+ const startTime = [
311
+ String(due.getHours()).padStart(2, '0'),
312
+ String(due.getMinutes()).padStart(2, '0'),
313
+ ].join(':')
314
+
315
+ // PowerShell command that shows a toast notification
316
+ const title = task.title.replace(/'/g, "''").replace(/"/g, '\\"')
317
+ const toastPs = [
318
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null;',
319
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null;',
320
+ `$x = New-Object Windows.Data.Xml.Dom.XmlDocument;`,
321
+ `$x.LoadXml('<toast><visual><binding template=""ToastText02""><text id=""1"">smolerclaw</text><text id=""2"">${title}</text></binding></visual><audio src=""ms-winsoundevent:Notification.Reminder""/></toast>');`,
322
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('smolerclaw').Show([Windows.UI.Notifications.ToastNotification]::new($x))`,
323
+ ].join(' ')
324
+
325
+ try {
326
+ const proc = Bun.spawn([
327
+ 'schtasks', '/Create',
328
+ '/TN', taskName,
329
+ '/SC', 'ONCE',
330
+ '/SD', startDate,
331
+ '/ST', startTime,
332
+ '/TR', `powershell -NoProfile -WindowStyle Hidden -Command "${toastPs}"`,
333
+ '/F', // force overwrite if exists
334
+ ], { stdout: 'pipe', stderr: 'pipe' })
335
+ await proc.exited
336
+ } catch {
337
+ // Best effort — scheduler failure should not block task creation
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Remove a scheduled Windows task by task ID.
343
+ */
344
+ async function removeWindowsTask(taskId: string): Promise<void> {
345
+ const taskName = `${TASK_PREFIX}${taskId}`
346
+ try {
347
+ const proc = Bun.spawn([
348
+ 'schtasks', '/Delete', '/TN', taskName, '/F',
349
+ ], { stdout: 'pipe', stderr: 'pipe' })
350
+ await proc.exited
351
+ } catch {
352
+ // Ignore — task may not exist
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Sync existing tasks with Task Scheduler on startup.
358
+ * Ensures pending reminders are scheduled even after a restart.
359
+ */
360
+ async function syncScheduledTasks(): Promise<void> {
361
+ if (!IS_WINDOWS) return
362
+
363
+ const now = Date.now()
364
+ for (const task of _tasks) {
365
+ if (task.done || task.notified || !task.dueAt) continue
366
+ const due = new Date(task.dueAt)
367
+ if (isNaN(due.getTime()) || due.getTime() <= now) continue
368
+
369
+ // Check if the scheduled task exists
370
+ try {
371
+ const proc = Bun.spawn([
372
+ 'schtasks', '/Query', '/TN', `${TASK_PREFIX}${task.id}`,
373
+ ], { stdout: 'pipe', stderr: 'pipe' })
374
+ const code = await proc.exited
375
+ if (code !== 0) {
376
+ // Task doesn't exist in scheduler — recreate it
377
+ await scheduleWindowsTask(task)
378
+ }
379
+ } catch {
380
+ await scheduleWindowsTask(task)
381
+ }
382
+ }
383
+ }
384
+
385
+ // ─── Helpers ────────────────────────────────────────────────
386
+
387
+ function generateId(): string {
388
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
389
+ let id = ''
390
+ for (let i = 0; i < 6; i++) {
391
+ id += chars[Math.floor(Math.random() * chars.length)]
392
+ }
393
+ return id
394
+ }
395
+
396
+ function formatDueTime(isoDate: string): string {
397
+ const date = new Date(isoDate)
398
+ if (isNaN(date.getTime())) return '?'
399
+
400
+ const now = new Date()
401
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
402
+ const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
403
+
404
+ const time = date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
405
+
406
+ if (target.getTime() === today.getTime()) {
407
+ return `hoje ${time}`
408
+ }
409
+
410
+ const tomorrow = new Date(today)
411
+ tomorrow.setDate(tomorrow.getDate() + 1)
412
+ if (target.getTime() === tomorrow.getTime()) {
413
+ return `amanha ${time}`
414
+ }
415
+
416
+ const dateStr = date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
417
+ return `${dateStr} ${time}`
418
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,102 @@
1
+ export interface TokenUsage {
2
+ inputTokens: number
3
+ outputTokens: number
4
+ }
5
+
6
+ export interface CostEstimate {
7
+ inputCostCents: number
8
+ outputCostCents: number
9
+ totalCostCents: number
10
+ }
11
+
12
+ // Pricing per 1M tokens in USD (as of 2025)
13
+ const PRICING: Record<string, { input: number; output: number }> = {
14
+ 'claude-haiku-4-5-20251001': { input: 1.00, output: 5.00 },
15
+ 'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
16
+ 'claude-sonnet-4-6-20250627': { input: 3.00, output: 15.00 },
17
+ 'claude-opus-4-20250514': { input: 15.00, output: 75.00 },
18
+ 'claude-opus-4-6-20250318': { input: 15.00, output: 75.00 },
19
+ }
20
+
21
+ // Fallback for unknown models (conservative estimate)
22
+ const DEFAULT_PRICING = { input: 3.00, output: 15.00 }
23
+
24
+ /**
25
+ * Estimate cost for a given token usage and model.
26
+ */
27
+ export function estimateCost(usage: TokenUsage, model: string): CostEstimate {
28
+ const pricing = findPricing(model)
29
+ const inputCostCents = (usage.inputTokens / 1_000_000) * pricing.input * 100
30
+ const outputCostCents = (usage.outputTokens / 1_000_000) * pricing.output * 100
31
+ return {
32
+ inputCostCents,
33
+ outputCostCents,
34
+ totalCostCents: inputCostCents + outputCostCents,
35
+ }
36
+ }
37
+
38
+ function findPricing(model: string): { input: number; output: number } {
39
+ // Exact match
40
+ if (PRICING[model]) return PRICING[model]
41
+
42
+ // Pattern match (e.g., "claude-haiku" matches "claude-haiku-4-5-*")
43
+ const lower = model.toLowerCase()
44
+ if (lower.includes('haiku')) return PRICING['claude-haiku-4-5-20251001']
45
+ if (lower.includes('opus')) return PRICING['claude-opus-4-20250514']
46
+ if (lower.includes('sonnet')) return PRICING['claude-sonnet-4-20250514']
47
+
48
+ return DEFAULT_PRICING
49
+ }
50
+
51
+ /**
52
+ * Tracks cumulative token usage across a session.
53
+ */
54
+ export class TokenTracker {
55
+ private totalInput = 0
56
+ private totalOutput = 0
57
+ private totalCostCents = 0
58
+ private model: string
59
+
60
+ constructor(model: string) {
61
+ this.model = model
62
+ }
63
+
64
+ setModel(model: string): void {
65
+ this.model = model
66
+ }
67
+
68
+ add(usage: TokenUsage): CostEstimate {
69
+ this.totalInput += usage.inputTokens
70
+ this.totalOutput += usage.outputTokens
71
+ const cost = estimateCost(usage, this.model)
72
+ this.totalCostCents += cost.totalCostCents
73
+ return cost
74
+ }
75
+
76
+ get totals(): { inputTokens: number; outputTokens: number; costCents: number } {
77
+ return {
78
+ inputTokens: this.totalInput,
79
+ outputTokens: this.totalOutput,
80
+ costCents: this.totalCostCents,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Format a single response's usage for display.
86
+ */
87
+ formatUsage(usage: TokenUsage): string {
88
+ const cost = estimateCost(usage, this.model)
89
+ return `${fmt(usage.inputTokens)} in / ${fmt(usage.outputTokens)} out (~$${(cost.totalCostCents / 100).toFixed(4)})`
90
+ }
91
+
92
+ /**
93
+ * Format cumulative session usage.
94
+ */
95
+ formatSession(): string {
96
+ return `${fmt(this.totalInput)} in / ${fmt(this.totalOutput)} out | session: ~$${(this.totalCostCents / 100).toFixed(4)}`
97
+ }
98
+ }
99
+
100
+ function fmt(n: number): string {
101
+ return n.toLocaleString('en-US')
102
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tool safety layer.
3
+ * Classifies tool calls by risk level and detects dangerous patterns.
4
+ */
5
+
6
+ export type RiskLevel = 'safe' | 'moderate' | 'dangerous'
7
+
8
+ interface ToolRisk {
9
+ level: RiskLevel
10
+ reason?: string
11
+ }
12
+
13
+ // Dangerous command patterns (case-insensitive)
14
+ const DANGEROUS_COMMANDS = [
15
+ /\brm\s+(-rf?|--recursive)/i,
16
+ /\brmdir\s/i,
17
+ /\bdel\s+\/[sS]/i, // Windows: del /S
18
+ /\bRemove-Item\s.*-Recurse/i, // PowerShell
19
+ /\bformat\s+[a-z]:/i, // Windows: format C:
20
+ /\bgit\s+(push\s+--force|reset\s+--hard|clean\s+-fd)/i,
21
+ /\bdrop\s+(table|database)/i,
22
+ /\btruncate\s+table/i,
23
+ /\bchmod\s+777/i,
24
+ /\bchown\s+-R/i,
25
+ /\bcurl\s.*\|\s*(bash|sh)/i, // Pipe to shell
26
+ /\bwget\s.*\|\s*(bash|sh)/i,
27
+ /\bnpm\s+publish/i,
28
+ /\bsudo\s/i,
29
+ /\bkill\s+-9/i,
30
+ /\bshutdown/i,
31
+ /\breboot/i,
32
+ ]
33
+
34
+ // Patterns that indicate elevated risk but are common
35
+ const MODERATE_COMMANDS = [
36
+ /\bgit\s+push/i,
37
+ /\bgit\s+commit/i,
38
+ /\bnpm\s+install/i,
39
+ /\bbun\s+(install|add)/i,
40
+ /\bpip\s+install/i,
41
+ /\bcargo\s+install/i,
42
+ /\bmkdir\s+-p/i,
43
+ ]
44
+
45
+ /**
46
+ * Assess risk level of a tool call.
47
+ */
48
+ export function assessToolRisk(name: string, input: Record<string, unknown>): ToolRisk {
49
+ switch (name) {
50
+ case 'read_file':
51
+ case 'list_directory':
52
+ case 'find_files':
53
+ case 'search_files':
54
+ case 'fetch_url':
55
+ return { level: 'safe' }
56
+
57
+ case 'write_file':
58
+ return { level: 'moderate', reason: `write ${input.path}` }
59
+
60
+ case 'edit_file':
61
+ return { level: 'moderate', reason: `edit ${input.path}` }
62
+
63
+ case 'run_command': {
64
+ const cmd = String(input.command || '')
65
+
66
+ // Check dangerous patterns first
67
+ for (const pattern of DANGEROUS_COMMANDS) {
68
+ if (pattern.test(cmd)) {
69
+ return { level: 'dangerous', reason: cmd }
70
+ }
71
+ }
72
+
73
+ // Check moderate patterns
74
+ for (const pattern of MODERATE_COMMANDS) {
75
+ if (pattern.test(cmd)) {
76
+ return { level: 'moderate', reason: cmd }
77
+ }
78
+ }
79
+
80
+ return { level: 'moderate', reason: cmd }
81
+ }
82
+
83
+ default:
84
+ return { level: 'moderate', reason: `unknown tool: ${name}` }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Format a risk assessment for display.
90
+ */
91
+ export function formatRisk(risk: ToolRisk): string {
92
+ switch (risk.level) {
93
+ case 'safe':
94
+ return ''
95
+ case 'moderate':
96
+ return risk.reason || 'modification'
97
+ case 'dangerous':
98
+ return `DANGEROUS: ${risk.reason}`
99
+ }
100
+ }