@stevederico/dotbot 0.27.0 → 0.28.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ 0.28
2
+
3
+ Add --sandbox mode
4
+ Add --allow domain presets
5
+ Add domain-gated web_fetch
6
+ Add domain-gated browser_navigate
7
+ Add preset-unlocked tools
8
+ Add API key validation
9
+ Add models CLI command
10
+ Add /models REPL command
11
+ Add /load model command
12
+
1
13
  0.27
2
14
 
3
15
  Add interactive API key prompt
package/bin/dotbot.js CHANGED
@@ -100,6 +100,7 @@ Usage:
100
100
  echo "msg" | dotbot Pipe input from stdin
101
101
 
102
102
  Commands:
103
+ models List available models from provider
103
104
  doctor Check environment and configuration
104
105
  tools List all available tools
105
106
  stats Show database statistics
@@ -121,6 +122,8 @@ Options:
121
122
  --db SQLite database path (default: ${DEFAULT_DB})
122
123
  --port Server port for 'serve' command (default: ${DEFAULT_PORT})
123
124
  --openai Enable OpenAI-compatible API endpoints (/v1/chat/completions, /v1/models)
125
+ --sandbox Restrict tools to safe subset (no files, code, browser, messages)
126
+ --allow Allow domain/preset in sandbox (github, slack, discord, npm, etc.)
124
127
  --json Output as JSON (for inspection commands)
125
128
  --verbose Show initialization logs
126
129
  --help, -h Show this help
@@ -138,16 +141,20 @@ Config File:
138
141
  Examples:
139
142
  dotbot "What's the weather in SF?"
140
143
  dotbot
144
+ dotbot -p anthropic -m claude-sonnet-4-5 "Hello"
145
+ dotbot -p ollama -m llama3 "Summarize this"
146
+ dotbot -p openai -m gpt-4o
147
+ dotbot models
141
148
  dotbot serve --port 8080
142
149
  dotbot doctor
143
150
  dotbot tools
144
151
  dotbot memory search "preferences"
145
- dotbot memory delete user_pref
146
152
  dotbot stats --json
153
+ dotbot --sandbox "What is 2+2?"
154
+ dotbot --sandbox --allow github "Check my repo"
147
155
  dotbot --system "You are a pirate" "Hello"
148
156
  dotbot --session abc-123 "follow up question"
149
157
  echo "What is 2+2?" | dotbot
150
- cat question.txt | dotbot
151
158
  `);
152
159
  }
153
160
 
@@ -204,10 +211,17 @@ function parseCliArgs() {
204
211
  port: { type: 'string' },
205
212
  openai: { type: 'boolean', default: false },
206
213
  session: { type: 'string', default: '' },
214
+ sandbox: { type: 'boolean', default: false },
215
+ allow: { type: 'string', multiple: true },
207
216
  },
208
217
  });
209
218
 
210
219
  // Merge: CLI args > config file > hardcoded defaults
220
+ // Build sandbox domain allowlist from --allow flags, presets, and config
221
+ const allowFlags = values.allow || [];
222
+ const configAllow = config.sandboxAllow || [];
223
+ const allAllow = [...allowFlags, ...configAllow];
224
+
211
225
  return {
212
226
  ...values,
213
227
  provider: values.provider ?? config.provider ?? 'xai',
@@ -216,6 +230,8 @@ function parseCliArgs() {
216
230
  db: values.db ?? config.db ?? DEFAULT_DB,
217
231
  port: values.port ?? config.port ?? String(DEFAULT_PORT),
218
232
  session: values.session ?? '',
233
+ sandbox: values.sandbox || config.sandbox || false,
234
+ sandboxAllow: allAllow,
219
235
  positionals,
220
236
  };
221
237
  } catch (err) {
@@ -297,6 +313,18 @@ async function getProviderConfig(providerId) {
297
313
  process.exit(1);
298
314
  }
299
315
 
316
+ // Validate key by fetching models
317
+ process.stdout.write('Validating');
318
+ startSpinner();
319
+ const { ok } = await fetchProviderModels(providerId, apiKey);
320
+ if (ok) {
321
+ stopSpinner('valid');
322
+ } else {
323
+ stopSpinner('failed');
324
+ console.error(`Could not authenticate with ${base.name}. Check your API key and try again.`);
325
+ process.exit(1);
326
+ }
327
+
300
328
  const save = await askUser('Save to ~/.dotbotrc for next time? (Y/n) ');
301
329
  if (save.toLowerCase() !== 'n') {
302
330
  saveToConfig('env', { ...loadConfig().env, [envKey]: apiKey });
@@ -328,6 +356,201 @@ function getProviderSignupUrl(providerId) {
328
356
  return urls[providerId] || 'the provider\'s website';
329
357
  }
330
358
 
359
+ /**
360
+ * Fetch available models from a provider's API.
361
+ *
362
+ * @param {string} providerId - Provider ID
363
+ * @param {string} apiKey - API key for authentication
364
+ * @returns {Promise<{ok: boolean, models: Array<{id: string, name: string}>}>} Validation result with model list
365
+ */
366
+ async function fetchProviderModels(providerId, apiKey) {
367
+ await loadModules();
368
+ const base = AI_PROVIDERS[providerId];
369
+ if (!base) return { ok: false, models: [] };
370
+
371
+ let url;
372
+ let headers;
373
+
374
+ if (providerId === 'ollama') {
375
+ const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
376
+ url = `${baseUrl}/api/tags`;
377
+ headers = { 'Content-Type': 'application/json' };
378
+ } else {
379
+ url = `${base.apiUrl}/models`;
380
+ headers = base.headers(apiKey);
381
+ }
382
+
383
+ try {
384
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
385
+ if (!res.ok) return { ok: false, models: [] };
386
+
387
+ const data = await res.json();
388
+
389
+ let models = [];
390
+ if (providerId === 'ollama') {
391
+ models = (data.models || []).map((m) => ({ id: m.name, name: m.name }));
392
+ } else if (providerId === 'anthropic') {
393
+ models = (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
394
+ } else {
395
+ models = (data.data || []).map((m) => ({ id: m.id, name: m.id }));
396
+ }
397
+
398
+ return { ok: true, models };
399
+ } catch {
400
+ return { ok: false, models: [] };
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Tools always allowed in sandbox mode (safe, internal-only).
406
+ */
407
+ const SANDBOX_ALLOWED_TOOLS = new Set([
408
+ 'memory_save', 'memory_search', 'memory_delete', 'memory_list', 'memory_read', 'memory_update',
409
+ 'web_search', 'grokipedia_search',
410
+ 'file_read', 'file_list',
411
+ 'run_code',
412
+ 'weather_get',
413
+ 'event_query', 'events_summary',
414
+ 'task_create', 'task_list', 'task_plan', 'task_work', 'task_step_done', 'task_complete',
415
+ 'task_delete', 'task_search', 'task_stats',
416
+ 'trigger_create', 'trigger_list', 'trigger_toggle', 'trigger_delete',
417
+ 'schedule_job', 'list_jobs', 'toggle_job', 'cancel_job',
418
+ ]);
419
+
420
+ /** Tools that are allowed in sandbox but domain-gated via allowlist. */
421
+ const SANDBOX_GATED_TOOLS = new Set(['web_fetch', 'browser_navigate']);
422
+
423
+ /** Tools unlocked in sandbox when their preset is in the --allow list. */
424
+ const SANDBOX_PRESET_TOOLS = {
425
+ messages: ['message_list', 'message_send', 'message_delete', 'message_read'],
426
+ images: ['image_generate', 'image_list', 'image_search'],
427
+ notifications: ['notify_user'],
428
+ };
429
+
430
+ /**
431
+ * Domain presets matching NemoClaw's policy preset pattern.
432
+ * Each preset maps to a list of allowed domains.
433
+ */
434
+ const DOMAIN_PRESETS = {
435
+ github: ['github.com', 'api.github.com', 'raw.githubusercontent.com'],
436
+ slack: ['slack.com', 'api.slack.com'],
437
+ discord: ['discord.com', 'api.discord.com'],
438
+ npm: ['registry.npmjs.org', 'www.npmjs.com'],
439
+ pypi: ['pypi.org', 'files.pythonhosted.org'],
440
+ jira: ['atlassian.net', 'jira.atlassian.com'],
441
+ huggingface: ['huggingface.co', 'api-inference.huggingface.co'],
442
+ docker: ['hub.docker.com', 'registry-1.docker.io'],
443
+ telegram: ['api.telegram.org'],
444
+ };
445
+
446
+ /**
447
+ * Resolve --allow values into a Set of allowed domains.
448
+ * Accepts preset names (e.g., "github") or raw domains (e.g., "api.example.com").
449
+ *
450
+ * @param {Array<string>} allowList - Preset names or domain names
451
+ * @returns {Set<string>} Resolved domain set
452
+ */
453
+ function resolveSandboxDomains(allowList = []) {
454
+ const domains = new Set();
455
+ for (const entry of allowList) {
456
+ const lower = entry.toLowerCase();
457
+ if (DOMAIN_PRESETS[lower]) {
458
+ for (const d of DOMAIN_PRESETS[lower]) domains.add(d);
459
+ } else {
460
+ domains.add(lower);
461
+ }
462
+ }
463
+ return domains;
464
+ }
465
+
466
+ /**
467
+ * Check if a URL's hostname matches the sandbox domain allowlist.
468
+ *
469
+ * @param {string} urlStr - URL to check
470
+ * @param {Set<string>} allowedDomains - Set of allowed domain names
471
+ * @returns {boolean} Whether the domain is allowed
472
+ */
473
+ function isDomainAllowed(urlStr, allowedDomains) {
474
+ try {
475
+ const { hostname } = new URL(urlStr);
476
+ if (allowedDomains.has(hostname)) return true;
477
+ // Support wildcard subdomains (e.g., "atlassian.net" matches "myteam.atlassian.net")
478
+ for (const domain of allowedDomains) {
479
+ if (hostname.endsWith(`.${domain}`)) return true;
480
+ }
481
+ return false;
482
+ } catch {
483
+ return false;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Wrap a tool's execute function with domain enforcement.
489
+ *
490
+ * @param {Object} tool - Tool definition
491
+ * @param {Set<string>} allowedDomains - Allowed domains
492
+ * @returns {Object} Tool with domain-gated execute
493
+ */
494
+ function wrapWithDomainGate(tool, allowedDomains) {
495
+ const original = tool.execute;
496
+ return {
497
+ ...tool,
498
+ description: `${tool.description} [SANDBOX: restricted to allowed domains]`,
499
+ execute: async (input, signal, context) => {
500
+ const url = input.url;
501
+ if (!url || !isDomainAllowed(url, allowedDomains)) {
502
+ const allowed = [...allowedDomains].join(', ') || 'none';
503
+ return `Blocked by sandbox policy. Domain not in allowlist.\nAllowed domains: ${allowed}`;
504
+ }
505
+ return original(input, signal, context);
506
+ },
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Get tools filtered by sandbox mode with domain-gated network access.
512
+ *
513
+ * Mirrors NemoClaw's deny-by-default policy:
514
+ * - No filesystem access (file_*)
515
+ * - No code execution (run_code)
516
+ * - No outbound messaging (message_*)
517
+ * - No image generation, notifications, or app scaffolding
518
+ * - Network tools (web_fetch, browser_navigate) restricted to domain allowlist
519
+ * - Curated search APIs (web_search, grokipedia_search) always allowed
520
+ *
521
+ * @param {boolean} sandbox - Whether sandbox mode is active
522
+ * @param {Array<string>} allowList - Domain presets or raw domains to allow
523
+ * @returns {Array} Filtered and gated tools
524
+ */
525
+ function getActiveTools(sandbox = false, allowList = []) {
526
+ if (!sandbox) return coreTools;
527
+
528
+ const allowedDomains = resolveSandboxDomains(allowList);
529
+ const allowLower = new Set(allowList.map((a) => a.toLowerCase()));
530
+
531
+ // Build set of preset-unlocked tool names
532
+ const presetUnlocked = new Set();
533
+ for (const [preset, toolNames] of Object.entries(SANDBOX_PRESET_TOOLS)) {
534
+ if (allowLower.has(preset)) {
535
+ for (const name of toolNames) presetUnlocked.add(name);
536
+ }
537
+ }
538
+
539
+ const tools = [];
540
+
541
+ for (const tool of coreTools) {
542
+ if (SANDBOX_ALLOWED_TOOLS.has(tool.name)) {
543
+ tools.push(tool);
544
+ } else if (SANDBOX_GATED_TOOLS.has(tool.name) && allowedDomains.size > 0) {
545
+ tools.push(wrapWithDomainGate(tool, allowedDomains));
546
+ } else if (presetUnlocked.has(tool.name)) {
547
+ tools.push(tool);
548
+ }
549
+ }
550
+
551
+ return tools;
552
+ }
553
+
331
554
  /**
332
555
  * Initialize stores.
333
556
  *
@@ -421,7 +644,7 @@ async function runChat(message, options) {
421
644
  for await (const event of agentLoop({
422
645
  model: options.model,
423
646
  messages,
424
- tools: coreTools,
647
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
425
648
  provider,
426
649
  context,
427
650
  })) {
@@ -511,7 +734,7 @@ async function runRepl(options) {
511
734
  output: process.stdout,
512
735
  });
513
736
 
514
- console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}`);
737
+ console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}${options.sandbox ? ' (sandbox)' : ''}`);
515
738
  if (options.session) {
516
739
  console.log(`Resuming session: ${session.id}`);
517
740
  }
@@ -519,6 +742,8 @@ async function runRepl(options) {
519
742
 
520
743
  const showHelp = () => {
521
744
  console.log('Available Commands:');
745
+ console.log(' /models List available models from provider');
746
+ console.log(' /load <model> Switch to a different model');
522
747
  console.log(' /show Show model information');
523
748
  console.log(' /clear Clear session context');
524
749
  console.log(' /bye Exit');
@@ -588,6 +813,50 @@ async function runRepl(options) {
588
813
  return;
589
814
  }
590
815
 
816
+ if (trimmed === '/models') {
817
+ const apiKey = process.env[AI_PROVIDERS[options.provider]?.envKey];
818
+ process.stdout.write('Fetching models');
819
+ startSpinner();
820
+ const { ok, models } = await fetchProviderModels(options.provider, apiKey);
821
+ if (ok && models.length) {
822
+ stopSpinner('');
823
+ console.log('');
824
+ for (const m of models) {
825
+ const active = m.id === options.model ? ' (active)' : '';
826
+ console.log(` ${m.id}${active}`);
827
+ }
828
+ console.log('');
829
+ } else {
830
+ stopSpinner('');
831
+ // Fall back to static list
832
+ const base = AI_PROVIDERS[options.provider];
833
+ if (base.models?.length) {
834
+ console.log('');
835
+ for (const m of base.models) {
836
+ const active = m.id === options.model ? ' (active)' : '';
837
+ console.log(` ${m.id}${active}`);
838
+ }
839
+ console.log('');
840
+ } else {
841
+ console.log('\nNo models found.\n');
842
+ }
843
+ }
844
+ promptUser();
845
+ return;
846
+ }
847
+
848
+ if (trimmed.startsWith('/load ')) {
849
+ const newModel = trimmed.slice(6).trim();
850
+ if (!newModel) {
851
+ console.log('Usage: /load <model-name>\n');
852
+ } else {
853
+ options.model = newModel;
854
+ console.log(`Switched to ${newModel}\n`);
855
+ }
856
+ promptUser();
857
+ return;
858
+ }
859
+
591
860
  await handleMessage(trimmed);
592
861
  });
593
862
  };
@@ -606,7 +875,7 @@ async function runRepl(options) {
606
875
  for await (const event of agentLoop({
607
876
  model: options.model,
608
877
  messages: [...messages],
609
- tools: coreTools,
878
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
610
879
  provider,
611
880
  context,
612
881
  })) {
@@ -771,7 +1040,7 @@ async function runServer(options) {
771
1040
  for await (const event of agentLoop({
772
1041
  model,
773
1042
  messages: reqMessages,
774
- tools: coreTools,
1043
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
775
1044
  provider,
776
1045
  context,
777
1046
  })) {
@@ -804,7 +1073,7 @@ async function runServer(options) {
804
1073
  for await (const event of agentLoop({
805
1074
  model,
806
1075
  messages: reqMessages,
807
- tools: coreTools,
1076
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
808
1077
  provider,
809
1078
  context,
810
1079
  })) {
@@ -878,7 +1147,7 @@ async function runServer(options) {
878
1147
  for await (const event of agentLoop({
879
1148
  model,
880
1149
  messages,
881
- tools: coreTools,
1150
+ tools: getActiveTools(options.sandbox, options.sandboxAllow),
882
1151
  provider,
883
1152
  context,
884
1153
  })) {
@@ -1265,6 +1534,62 @@ async function runDoctor(options) {
1265
1534
  console.log();
1266
1535
  }
1267
1536
 
1537
+ /**
1538
+ * List available models from the provider's API.
1539
+ *
1540
+ * @param {Object} options - CLI options
1541
+ */
1542
+ async function runModels(options) {
1543
+ await loadModules();
1544
+ const base = AI_PROVIDERS[options.provider];
1545
+ if (!base) {
1546
+ console.error(`Unknown provider: ${options.provider}`);
1547
+ process.exit(1);
1548
+ }
1549
+
1550
+ const apiKey = process.env[base.envKey];
1551
+ if (!apiKey && options.provider !== 'ollama') {
1552
+ console.error(`Missing ${base.envKey} — set it or run dotbot to configure interactively.`);
1553
+ process.exit(1);
1554
+ }
1555
+
1556
+ process.stdout.write('Fetching models');
1557
+ startSpinner();
1558
+
1559
+ const { ok, models } = await fetchProviderModels(options.provider, apiKey);
1560
+
1561
+ if (!ok) {
1562
+ stopSpinner('failed');
1563
+ console.error(`Could not fetch models from ${base.name}. Check your API key.`);
1564
+
1565
+ // Fall back to static list
1566
+ if (base.models?.length) {
1567
+ console.log(`\nLocal model list (${base.name}):\n`);
1568
+ for (const m of base.models) {
1569
+ const active = m.id === options.model ? ' (active)' : '';
1570
+ console.log(` ${m.id}${active}`);
1571
+ }
1572
+ console.log();
1573
+ }
1574
+ return;
1575
+ }
1576
+
1577
+ stopSpinner('');
1578
+
1579
+ if (options.json) {
1580
+ console.log(JSON.stringify(models));
1581
+ return;
1582
+ }
1583
+
1584
+ console.log(`\n${base.name} models (${models.length})\n`);
1585
+ for (const m of models) {
1586
+ const active = m.id === options.model ? ' (active)' : '';
1587
+ const label = m.name !== m.id ? ` — ${m.name}` : '';
1588
+ console.log(` ${m.id}${label}${active}`);
1589
+ }
1590
+ console.log();
1591
+ }
1592
+
1268
1593
  /**
1269
1594
  * Main entry point.
1270
1595
  */
@@ -1297,6 +1622,9 @@ async function main() {
1297
1622
  }
1298
1623
 
1299
1624
  switch (command) {
1625
+ case 'models':
1626
+ await runModels(args);
1627
+ break;
1300
1628
  case 'doctor':
1301
1629
  await runDoctor(args);
1302
1630
  break;
package/dotbot.db CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevederico/dotbot",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
5
5
  "type": "module",
6
6
  "main": "index.js",