obol-ai 0.2.8 → 0.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/claude.js CHANGED
@@ -578,6 +578,7 @@ You communicate via Telegram. Format responses for mobile readability.
578
578
  - Store important info in memory proactively
579
579
  - Search memory before claiming you don't know something
580
580
  - Use \`store_secret\`/\`read_secret\` for all credential operations
581
+ - If a user sends what appears to be an API key, token, or credential in conversation, immediately warn them that it's visible in chat history, tell them to revoke/rotate it, and direct them to use \`/secret set <key> <value>\` instead
581
582
  `);
582
583
 
583
584
  return parts.join('\n');
package/src/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const crypto = require('crypto');
4
3
  const os = require('os');
4
+ const { deriveKey, encrypt, decrypt } = require('./encrypt');
5
5
 
6
6
  const OBOL_DIR = path.join(os.homedir(), '.obol');
7
7
  const USERS_DIR = path.join(OBOL_DIR, 'users');
@@ -34,6 +34,71 @@ function resolvePassValues(obj) {
34
34
  return result;
35
35
  }
36
36
 
37
+ const SENSITIVE_PATHS = [
38
+ 'anthropic.apiKey',
39
+ 'anthropic.oauth.accessToken',
40
+ 'anthropic.oauth.refreshToken',
41
+ 'telegram.token',
42
+ 'supabase.serviceKey',
43
+ 'supabase.accessToken',
44
+ 'github.token',
45
+ 'vercel.token',
46
+ ];
47
+
48
+ function configKey() {
49
+ return deriveKey('obol-config');
50
+ }
51
+
52
+ function getPath(obj, dotPath) {
53
+ return dotPath.split('.').reduce((o, k) => o?.[k], obj);
54
+ }
55
+
56
+ function setPath(obj, dotPath, value) {
57
+ const parts = dotPath.split('.');
58
+ let cur = obj;
59
+ for (let i = 0; i < parts.length - 1; i++) {
60
+ if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') return;
61
+ cur = cur[parts[i]];
62
+ }
63
+ cur[parts[parts.length - 1]] = value;
64
+ }
65
+
66
+ function encryptSensitiveFields(config) {
67
+ const key = configKey();
68
+ const copy = JSON.parse(JSON.stringify(config));
69
+ for (const p of SENSITIVE_PATHS) {
70
+ const val = getPath(copy, p);
71
+ if (typeof val === 'string' && val && !val.startsWith('pass:')) {
72
+ setPath(copy, p, encrypt(val, key));
73
+ }
74
+ }
75
+ return copy;
76
+ }
77
+
78
+ const ENCRYPTED_RE = /^[0-9a-f]{32}:[0-9a-f]{32}:[0-9a-f]+$/;
79
+
80
+ function decryptSensitiveFields(config) {
81
+ const key = configKey();
82
+ let hadPlaintext = false;
83
+ for (const p of SENSITIVE_PATHS) {
84
+ const val = getPath(config, p);
85
+ if (typeof val === 'string' && val && !val.startsWith('pass:')) {
86
+ if (ENCRYPTED_RE.test(val)) {
87
+ try {
88
+ setPath(config, p, decrypt(val, key));
89
+ } catch {}
90
+ } else {
91
+ hadPlaintext = true;
92
+ }
93
+ }
94
+ }
95
+ if (hadPlaintext) {
96
+ const encrypted = encryptSensitiveFields(config);
97
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
98
+ }
99
+ return config;
100
+ }
101
+
37
102
  function loadConfig({ resolve = true } = {}) {
38
103
  if (!fs.existsSync(CONFIG_FILE)) return null;
39
104
  let raw;
@@ -51,6 +116,7 @@ function loadConfig({ resolve = true } = {}) {
51
116
  console.error('[config] Fix the file manually or run: obol init --reset');
52
117
  return null;
53
118
  }
119
+ decryptSensitiveFields(config);
54
120
  const warnings = validateConfigSchema(config);
55
121
  if (warnings.length > 0) {
56
122
  for (const w of warnings) console.warn(`[config] ${w}`);
@@ -74,7 +140,8 @@ function validateConfigSchema(config) {
74
140
 
75
141
  function saveConfig(config) {
76
142
  fs.mkdirSync(OBOL_DIR, { recursive: true });
77
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
143
+ const encrypted = encryptSensitiveFields(config);
144
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
78
145
  }
79
146
 
80
147
  function getUserDir(userId) {
@@ -2,6 +2,7 @@ const { execFileSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { getUserDir } = require('./config');
5
+ const { deriveKey, encrypt, decrypt } = require('./encrypt');
5
6
 
6
7
  const KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
7
8
 
@@ -25,6 +26,10 @@ function passPrefix(userId) {
25
26
  return `obol/users/${userId}`;
26
27
  }
27
28
 
29
+ function secretsKey(userId) {
30
+ return deriveKey(`obol-secrets-${userId}`);
31
+ }
32
+
28
33
  function secretsJsonPath(userId) {
29
34
  const dir = getUserDir(userId);
30
35
  return path.join(dir, 'secrets.json');
@@ -34,7 +39,13 @@ function loadSecretsJson(userId) {
34
39
  const p = secretsJsonPath(userId);
35
40
  if (!fs.existsSync(p)) return {};
36
41
  try {
37
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
42
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
43
+ const key = secretsKey(userId);
44
+ const decrypted = {};
45
+ for (const [k, v] of Object.entries(raw)) {
46
+ decrypted[k] = decrypt(v, key);
47
+ }
48
+ return decrypted;
38
49
  } catch {
39
50
  return {};
40
51
  }
@@ -43,7 +54,12 @@ function loadSecretsJson(userId) {
43
54
  function saveSecretsJson(userId, data) {
44
55
  const p = secretsJsonPath(userId);
45
56
  fs.mkdirSync(path.dirname(p), { recursive: true });
46
- fs.writeFileSync(p, JSON.stringify(data, null, 2), { mode: 0o600 });
57
+ const key = secretsKey(userId);
58
+ const encrypted = {};
59
+ for (const [k, v] of Object.entries(data)) {
60
+ encrypted[k] = encrypt(v, key);
61
+ }
62
+ fs.writeFileSync(p, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
47
63
  }
48
64
 
49
65
  function storeSecret(userId, key, value) {
@@ -119,8 +135,13 @@ function listSecrets(userId) {
119
135
  }
120
136
  }
121
137
 
122
- const secrets = loadSecretsJson(userId);
123
- return Object.keys(secrets);
138
+ const p = secretsJsonPath(userId);
139
+ if (!fs.existsSync(p)) return [];
140
+ try {
141
+ return Object.keys(JSON.parse(fs.readFileSync(p, 'utf-8')));
142
+ } catch {
143
+ return [];
144
+ }
124
145
  }
125
146
 
126
147
  module.exports = { storeSecret, readSecret, removeSecret, listSecrets, hasPassStore, validateKey };
@@ -60,7 +60,11 @@ Pattern: acknowledge immediately ("On it"), spawn the task, let it work in the b
60
60
  - Match the owner's energy and tone
61
61
  - Don't over-explain unless asked
62
62
  - Use tools proactively — don't describe what you could do, just do it
63
- - When unsure, ask one clear question rather than guessing
63
+ - Never ask more than one question per message
64
+ - If a reasonable default exists, use it — the user can correct you after
65
+ - Don't list numbered options unless the user asks "what are my options"
66
+ - Avoid emoji in responses — use plain text
67
+ - When a user asks you to build something, build it first, explain after
64
68
 
65
69
  ## Evolution
66
70
 
package/src/encrypt.js ADDED
@@ -0,0 +1,27 @@
1
+ const crypto = require('crypto');
2
+ const os = require('os');
3
+
4
+ /** @param {string} salt @returns {Buffer} */
5
+ function deriveKey(salt) {
6
+ const material = `${os.hostname()}:${process.getuid()}`;
7
+ return crypto.pbkdf2Sync(material, salt, 100_000, 32, 'sha256');
8
+ }
9
+
10
+ /** @param {string} text @param {Buffer} key @returns {string} */
11
+ function encrypt(text, key) {
12
+ const iv = crypto.randomBytes(16);
13
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
14
+ const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
15
+ const authTag = cipher.getAuthTag();
16
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
17
+ }
18
+
19
+ /** @param {string} encrypted @param {Buffer} key @returns {string} */
20
+ function decrypt(encrypted, key) {
21
+ const [ivHex, authTagHex, cipherHex] = encrypted.split(':');
22
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'));
23
+ decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
24
+ return Buffer.concat([decipher.update(Buffer.from(cipherHex, 'hex')), decipher.final()]).toString('utf8');
25
+ }
26
+
27
+ module.exports = { deriveKey, encrypt, decrypt };
package/src/telegram.js CHANGED
@@ -491,6 +491,24 @@ Your message is deleted immediately when using /secret set to keep credentials o
491
491
  return null;
492
492
  }
493
493
 
494
+ const API_KEY_PATTERNS = [
495
+ /sk-[a-zA-Z0-9]{20,}/,
496
+ /ghp_[a-zA-Z0-9]{36,}/,
497
+ /gho_[a-zA-Z0-9]{36,}/,
498
+ /ghu_[a-zA-Z0-9]{36,}/,
499
+ /ghs_[a-zA-Z0-9]{36,}/,
500
+ /github_pat_[a-zA-Z0-9_]{20,}/,
501
+ /xoxb-[a-zA-Z0-9\-]{20,}/,
502
+ /xoxp-[a-zA-Z0-9\-]{20,}/,
503
+ /xoxs-[a-zA-Z0-9\-]{20,}/,
504
+ /AKIA[A-Z0-9]{16}/,
505
+ /eyJ[a-zA-Z0-9_-]{50,}/,
506
+ ];
507
+
508
+ function containsApiKey(text) {
509
+ return API_KEY_PATTERNS.some(pattern => pattern.test(text));
510
+ }
511
+
494
512
  bot.on('message:text', async (ctx) => {
495
513
  if (!ctx.from) return;
496
514
  const userMessage = ctx.message.text;
@@ -503,6 +521,14 @@ Your message is deleted immediately when using /secret set to keep credentials o
503
521
  if (!userMessage.includes(`@${me.username}`)) return;
504
522
  }
505
523
 
524
+ if (!userMessage.startsWith('/secret') && containsApiKey(userMessage)) {
525
+ ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
526
+ await ctx.reply(
527
+ '⚠️ That message contained what looks like an API key or token. I deleted it, but it may have been seen already — consider rotating it.\n\nUse `/secret set <name> <value>` to store credentials safely.'
528
+ ).catch(() => {});
529
+ return;
530
+ }
531
+
506
532
  const rateResult = checkRateLimit(userId);
507
533
  if (rateResult === 'cooldown' || rateResult === 'skip') return;
508
534
  if (rateResult === 'spam') {