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 +1 -1
- package/src/claude.js +1 -0
- package/src/config.js +69 -2
- package/src/credentials.js +25 -4
- package/src/defaults/AGENTS.md +5 -1
- package/src/encrypt.js +27 -0
- package/src/telegram.js +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
|
|
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) {
|
package/src/credentials.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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 };
|
package/src/defaults/AGENTS.md
CHANGED
|
@@ -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
|
-
-
|
|
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') {
|