multis 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.
- package/.env.example +19 -0
- package/CLAUDE.md +66 -0
- package/README.md +98 -0
- package/package.json +32 -0
- package/skills/capture.md +60 -0
- package/skills/files.md +38 -0
- package/skills/shell.md +53 -0
- package/skills/weather.md +32 -0
- package/src/bot/handlers.js +712 -0
- package/src/bot/telegram.js +51 -0
- package/src/cli/setup-beeper.js +239 -0
- package/src/config.js +157 -0
- package/src/governance/audit.js +95 -0
- package/src/governance/validate.js +99 -0
- package/src/index.js +71 -0
- package/src/indexer/chunk.js +68 -0
- package/src/indexer/chunker.js +87 -0
- package/src/indexer/index.js +150 -0
- package/src/indexer/parsers.js +299 -0
- package/src/indexer/store.js +256 -0
- package/src/llm/anthropic.js +106 -0
- package/src/llm/base.js +38 -0
- package/src/llm/client.js +34 -0
- package/src/llm/ollama.js +148 -0
- package/src/llm/openai.js +107 -0
- package/src/llm/prompts.js +71 -0
- package/src/memory/capture.js +85 -0
- package/src/memory/manager.js +123 -0
- package/src/platforms/base.js +38 -0
- package/src/platforms/beeper.js +238 -0
- package/src/platforms/message.js +61 -0
- package/src/platforms/telegram.js +95 -0
- package/src/skills/executor.js +125 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { Telegraf } = require('telegraf');
|
|
2
|
+
const { logAudit } = require('../governance/audit');
|
|
3
|
+
const { DocumentIndexer } = require('../indexer/index');
|
|
4
|
+
const {
|
|
5
|
+
handleStart, handleStatus, handleUnpair,
|
|
6
|
+
handleExec, handleRead,
|
|
7
|
+
handleIndex, handleDocument, handleSearch, handleDocs,
|
|
8
|
+
handleSkills, handleHelp, handleMessage
|
|
9
|
+
} = require('./handlers');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create and configure the Telegram bot
|
|
13
|
+
* @param {Object} config - App configuration
|
|
14
|
+
* @returns {Telegraf} - Configured bot instance
|
|
15
|
+
*/
|
|
16
|
+
function createBot(config) {
|
|
17
|
+
if (!config.telegram_bot_token) {
|
|
18
|
+
throw new Error('TELEGRAM_BOT_TOKEN is required. Set it in .env or ~/.multis/config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const bot = new Telegraf(config.telegram_bot_token);
|
|
22
|
+
const indexer = new DocumentIndexer();
|
|
23
|
+
|
|
24
|
+
// Commands
|
|
25
|
+
bot.start(handleStart(config));
|
|
26
|
+
bot.command('status', handleStatus(config));
|
|
27
|
+
bot.command('exec', handleExec(config));
|
|
28
|
+
bot.command('read', handleRead(config));
|
|
29
|
+
bot.command('index', handleIndex(config, indexer));
|
|
30
|
+
bot.command('search', handleSearch(config, indexer));
|
|
31
|
+
bot.command('docs', handleDocs(config, indexer));
|
|
32
|
+
bot.command('skills', handleSkills(config));
|
|
33
|
+
bot.command('help', handleHelp(config));
|
|
34
|
+
bot.command('unpair', handleUnpair(config));
|
|
35
|
+
|
|
36
|
+
// Document uploads (PDF, DOCX, etc.)
|
|
37
|
+
bot.on('document', handleDocument(config, indexer));
|
|
38
|
+
|
|
39
|
+
// Text messages
|
|
40
|
+
bot.on('text', handleMessage(config));
|
|
41
|
+
|
|
42
|
+
// Log errors
|
|
43
|
+
bot.catch((err, ctx) => {
|
|
44
|
+
console.error('Bot error:', err.message);
|
|
45
|
+
logAudit({ action: 'error', error: err.message, update: ctx?.update?.update_id });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return bot;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createBot };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Beeper Desktop API onboarding.
|
|
4
|
+
*
|
|
5
|
+
* Guides the user through:
|
|
6
|
+
* 1. Installing Beeper Desktop and enabling the API
|
|
7
|
+
* 2. OAuth PKCE authentication
|
|
8
|
+
* 3. Verifying connected accounts
|
|
9
|
+
* 4. Enabling Beeper in multis config
|
|
10
|
+
*
|
|
11
|
+
* Run: node src/cli/setup-beeper.js
|
|
12
|
+
*/
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const readline = require('readline');
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const BASE = 'http://localhost:23373';
|
|
21
|
+
const MULTIS_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.multis');
|
|
22
|
+
const TOKEN_FILE = path.join(MULTIS_DIR, 'beeper-token.json');
|
|
23
|
+
const CONFIG_PATH = path.join(MULTIS_DIR, 'config.json');
|
|
24
|
+
|
|
25
|
+
function prompt(question) {
|
|
26
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
27
|
+
return new Promise(resolve => {
|
|
28
|
+
rl.question(question, answer => {
|
|
29
|
+
rl.close();
|
|
30
|
+
resolve(answer);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function checkDesktop() {
|
|
36
|
+
try {
|
|
37
|
+
await fetch(`${BASE}/v1/spec`, { signal: AbortSignal.timeout(2000) });
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadToken() {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveToken(tokenData) {
|
|
53
|
+
if (!fs.existsSync(MULTIS_DIR)) fs.mkdirSync(MULTIS_DIR, { recursive: true });
|
|
54
|
+
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function api(token, method, apiPath) {
|
|
58
|
+
const res = await fetch(`${BASE}${apiPath}`, {
|
|
59
|
+
method,
|
|
60
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
throw new Error(`${res.status}: ${text}`);
|
|
65
|
+
}
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function oauthPKCE() {
|
|
70
|
+
// Dynamic client registration
|
|
71
|
+
const regRes = await fetch(`${BASE}/oauth/register`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
client_name: 'multis',
|
|
76
|
+
redirect_uris: ['http://127.0.0.1:9876/callback'],
|
|
77
|
+
grant_types: ['authorization_code'],
|
|
78
|
+
response_types: ['code'],
|
|
79
|
+
token_endpoint_auth_method: 'none',
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
const client = await regRes.json();
|
|
83
|
+
const clientId = client.client_id;
|
|
84
|
+
|
|
85
|
+
// PKCE
|
|
86
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
87
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const server = http.createServer(async (req, res) => {
|
|
91
|
+
if (!req.url.startsWith('/callback')) return;
|
|
92
|
+
const url = new URL(req.url, 'http://127.0.0.1:9876');
|
|
93
|
+
const code = url.searchParams.get('code');
|
|
94
|
+
|
|
95
|
+
if (!code) {
|
|
96
|
+
res.writeHead(400);
|
|
97
|
+
res.end('No code received');
|
|
98
|
+
server.close();
|
|
99
|
+
reject(new Error('No auth code'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tokenRes = await fetch(`${BASE}/oauth/token`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
106
|
+
body: new URLSearchParams({
|
|
107
|
+
grant_type: 'authorization_code',
|
|
108
|
+
client_id: clientId,
|
|
109
|
+
code,
|
|
110
|
+
redirect_uri: 'http://127.0.0.1:9876/callback',
|
|
111
|
+
code_verifier: verifier,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
const tokenData = await tokenRes.json();
|
|
115
|
+
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
117
|
+
res.end('<h2>Authorized! You can close this tab.</h2>');
|
|
118
|
+
server.close();
|
|
119
|
+
|
|
120
|
+
saveToken(tokenData);
|
|
121
|
+
resolve(tokenData.access_token);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
server.listen(9876, '127.0.0.1', () => {
|
|
125
|
+
const authUrl = `${BASE}/oauth/authorize?` + new URLSearchParams({
|
|
126
|
+
response_type: 'code',
|
|
127
|
+
client_id: clientId,
|
|
128
|
+
redirect_uri: 'http://127.0.0.1:9876/callback',
|
|
129
|
+
code_challenge: challenge,
|
|
130
|
+
code_challenge_method: 'S256',
|
|
131
|
+
scope: 'read write',
|
|
132
|
+
});
|
|
133
|
+
console.log(' Opening browser for authorization...');
|
|
134
|
+
try {
|
|
135
|
+
execSync(`xdg-open "${authUrl}"`, { stdio: 'ignore' });
|
|
136
|
+
} catch {
|
|
137
|
+
console.log(` Open manually: ${authUrl}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
setTimeout(() => { server.close(); reject(new Error('OAuth timeout (60s)')); }, 60000);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function updateConfig() {
|
|
146
|
+
try {
|
|
147
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
148
|
+
if (!config.platforms) config.platforms = {};
|
|
149
|
+
if (!config.platforms.beeper) config.platforms.beeper = {};
|
|
150
|
+
config.platforms.beeper.enabled = true;
|
|
151
|
+
config.platforms.beeper.url = config.platforms.beeper.url || BASE;
|
|
152
|
+
config.platforms.beeper.command_prefix = config.platforms.beeper.command_prefix || '//';
|
|
153
|
+
config.platforms.beeper.poll_interval = config.platforms.beeper.poll_interval || 3000;
|
|
154
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
155
|
+
return true;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(` Could not update config: ${err.message}`);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
console.log('=== multis: Beeper Desktop Setup ===\n');
|
|
164
|
+
|
|
165
|
+
// Step 1: Instructions
|
|
166
|
+
console.log('Prerequisites:');
|
|
167
|
+
console.log(' 1. Install Beeper Desktop from https://beeper.com');
|
|
168
|
+
console.log(' 2. Sign in and connect your accounts (WhatsApp, etc.)');
|
|
169
|
+
console.log(' 3. Enable Desktop API: Settings > Developers > toggle on');
|
|
170
|
+
console.log();
|
|
171
|
+
|
|
172
|
+
await prompt('Press Enter when ready...');
|
|
173
|
+
|
|
174
|
+
// Step 2: Check Desktop is running
|
|
175
|
+
console.log('\n[1] Checking Beeper Desktop API...');
|
|
176
|
+
let reachable = await checkDesktop();
|
|
177
|
+
|
|
178
|
+
if (!reachable) {
|
|
179
|
+
console.log(' Not reachable at localhost:23373');
|
|
180
|
+
console.log(' Make sure Beeper Desktop is open and the API is enabled.');
|
|
181
|
+
await prompt('Press Enter to retry...');
|
|
182
|
+
reachable = await checkDesktop();
|
|
183
|
+
if (!reachable) {
|
|
184
|
+
console.error(' Still not reachable. Aborting.');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
console.log(' Desktop API is reachable.');
|
|
189
|
+
|
|
190
|
+
// Step 3: OAuth (reuse saved token if valid)
|
|
191
|
+
console.log('\n[2] Authentication...');
|
|
192
|
+
let token = null;
|
|
193
|
+
const saved = loadToken();
|
|
194
|
+
if (saved?.access_token) {
|
|
195
|
+
try {
|
|
196
|
+
await api(saved.access_token, 'GET', '/v1/accounts');
|
|
197
|
+
token = saved.access_token;
|
|
198
|
+
console.log(' Using existing token.');
|
|
199
|
+
} catch {
|
|
200
|
+
console.log(' Saved token expired, re-authenticating...');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!token) {
|
|
205
|
+
console.log(' Starting OAuth PKCE flow...');
|
|
206
|
+
token = await oauthPKCE();
|
|
207
|
+
console.log(' Authenticated!');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Step 4: List accounts
|
|
211
|
+
console.log('\n[3] Connected accounts:');
|
|
212
|
+
const accounts = await api(token, 'GET', '/v1/accounts');
|
|
213
|
+
const list = Array.isArray(accounts) ? accounts : accounts.items || [];
|
|
214
|
+
for (const acc of list) {
|
|
215
|
+
const name = acc.user?.displayText || acc.user?.id || acc.accountID || '?';
|
|
216
|
+
console.log(` - ${acc.network || '?'}: ${name}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (list.length === 0) {
|
|
220
|
+
console.log(' No accounts found. Connect accounts in Beeper Desktop first.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Step 5: Update config
|
|
224
|
+
console.log('\n[4] Updating multis config...');
|
|
225
|
+
if (updateConfig()) {
|
|
226
|
+
console.log(' Beeper enabled in ~/.multis/config.json');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Done
|
|
230
|
+
console.log('\n=== Setup complete! ===');
|
|
231
|
+
console.log('Start multis with: node src/index.js');
|
|
232
|
+
console.log(`Send ${list.length > 0 ? '//' : '//'}status from any Beeper chat to test.`);
|
|
233
|
+
console.log('Only messages starting with // from your accounts will be processed.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().catch(err => {
|
|
237
|
+
console.error('Error:', err.message);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
package/src/config.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const MULTIS_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.multis');
|
|
6
|
+
const CONFIG_PATH = path.join(MULTIS_DIR, 'config.json');
|
|
7
|
+
const GOVERNANCE_PATH = path.join(MULTIS_DIR, 'governance.json');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ensure ~/.multis directory exists with default config files
|
|
11
|
+
*/
|
|
12
|
+
function ensureMultisDir() {
|
|
13
|
+
if (!fs.existsSync(MULTIS_DIR)) {
|
|
14
|
+
fs.mkdirSync(MULTIS_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Copy default config if not present
|
|
18
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
19
|
+
const templateDir = path.join(__dirname, '..', '.multis-template');
|
|
20
|
+
fs.copyFileSync(path.join(templateDir, 'config.json'), CONFIG_PATH);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Copy default governance if not present
|
|
24
|
+
if (!fs.existsSync(GOVERNANCE_PATH)) {
|
|
25
|
+
const templateDir = path.join(__dirname, '..', '.multis-template');
|
|
26
|
+
fs.copyFileSync(path.join(templateDir, 'governance.json'), GOVERNANCE_PATH);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load .env file into process.env (simple key=value parser)
|
|
32
|
+
*/
|
|
33
|
+
function loadEnv() {
|
|
34
|
+
const envPath = path.join(__dirname, '..', '.env');
|
|
35
|
+
if (!fs.existsSync(envPath)) return;
|
|
36
|
+
|
|
37
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
38
|
+
for (const line of content.split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
41
|
+
const eqIndex = trimmed.indexOf('=');
|
|
42
|
+
if (eqIndex === -1) continue;
|
|
43
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
44
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
45
|
+
if (!process.env[key]) {
|
|
46
|
+
process.env[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a 6-character pairing code
|
|
53
|
+
*/
|
|
54
|
+
function generatePairingCode() {
|
|
55
|
+
return crypto.randomBytes(3).toString('hex').toUpperCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load and merge configuration from ~/.multis/config.json and .env
|
|
60
|
+
* .env values override config.json values
|
|
61
|
+
*/
|
|
62
|
+
function loadConfig() {
|
|
63
|
+
loadEnv();
|
|
64
|
+
ensureMultisDir();
|
|
65
|
+
|
|
66
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
67
|
+
|
|
68
|
+
// Ensure platforms block exists
|
|
69
|
+
if (!config.platforms) config.platforms = {};
|
|
70
|
+
if (!config.platforms.telegram) config.platforms.telegram = { enabled: true };
|
|
71
|
+
if (!config.platforms.beeper) config.platforms.beeper = { enabled: false };
|
|
72
|
+
|
|
73
|
+
// .env overrides
|
|
74
|
+
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
75
|
+
config.telegram_bot_token = process.env.TELEGRAM_BOT_TOKEN;
|
|
76
|
+
}
|
|
77
|
+
if (process.env.PAIRING_CODE) {
|
|
78
|
+
config.pairing_code = process.env.PAIRING_CODE;
|
|
79
|
+
}
|
|
80
|
+
if (process.env.LLM_PROVIDER) {
|
|
81
|
+
config.llm.provider = process.env.LLM_PROVIDER;
|
|
82
|
+
}
|
|
83
|
+
// Set API key based on active provider
|
|
84
|
+
const provider = config.llm.provider;
|
|
85
|
+
if (provider === 'anthropic' && process.env.ANTHROPIC_API_KEY) {
|
|
86
|
+
config.llm.apiKey = process.env.ANTHROPIC_API_KEY;
|
|
87
|
+
} else if (provider === 'openai' && process.env.OPENAI_API_KEY) {
|
|
88
|
+
config.llm.apiKey = process.env.OPENAI_API_KEY;
|
|
89
|
+
} else if (provider === 'gemini' && process.env.GEMINI_API_KEY) {
|
|
90
|
+
config.llm.apiKey = process.env.GEMINI_API_KEY;
|
|
91
|
+
}
|
|
92
|
+
if (process.env.LLM_MODEL) {
|
|
93
|
+
config.llm.model = process.env.LLM_MODEL;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Migrate: set first allowed user as owner if owner_id missing
|
|
97
|
+
if (!config.owner_id && config.allowed_users && config.allowed_users.length > 0) {
|
|
98
|
+
config.owner_id = config.allowed_users[0];
|
|
99
|
+
saveConfig(config);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Backward compat: sync telegram_bot_token into platforms block
|
|
103
|
+
if (config.telegram_bot_token && !config.platforms.telegram.bot_token) {
|
|
104
|
+
config.platforms.telegram.bot_token = config.telegram_bot_token;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Generate pairing code if not set
|
|
108
|
+
if (!config.pairing_code) {
|
|
109
|
+
config.pairing_code = generatePairingCode();
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return config;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Save config back to ~/.multis/config.json
|
|
118
|
+
*/
|
|
119
|
+
function saveConfig(config) {
|
|
120
|
+
ensureMultisDir();
|
|
121
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Add a user ID to the allowed users list.
|
|
126
|
+
* First paired user automatically becomes owner.
|
|
127
|
+
*/
|
|
128
|
+
function addAllowedUser(userId) {
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
if (!config.allowed_users.includes(userId)) {
|
|
131
|
+
config.allowed_users.push(userId);
|
|
132
|
+
}
|
|
133
|
+
// First paired user becomes owner
|
|
134
|
+
if (!config.owner_id) {
|
|
135
|
+
config.owner_id = userId;
|
|
136
|
+
}
|
|
137
|
+
saveConfig(config);
|
|
138
|
+
return config;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if a user is the owner
|
|
143
|
+
*/
|
|
144
|
+
function isOwner(userId, config) {
|
|
145
|
+
return config.owner_id === userId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
loadConfig,
|
|
150
|
+
saveConfig,
|
|
151
|
+
addAllowedUser,
|
|
152
|
+
isOwner,
|
|
153
|
+
generatePairingCode,
|
|
154
|
+
ensureMultisDir,
|
|
155
|
+
MULTIS_DIR,
|
|
156
|
+
CONFIG_PATH
|
|
157
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Log an action to the audit log (append-only, newline-delimited JSON)
|
|
6
|
+
* @param {Object} entry - Audit log entry
|
|
7
|
+
*/
|
|
8
|
+
function logAudit(entry) {
|
|
9
|
+
const auditPath = path.join(
|
|
10
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
11
|
+
'.multis',
|
|
12
|
+
'audit.log'
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const logEntry = {
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
...entry
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const line = JSON.stringify(logEntry) + '\n';
|
|
21
|
+
|
|
22
|
+
// Append-only (creates file if doesn't exist)
|
|
23
|
+
fs.appendFileSync(auditPath, line, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read recent audit logs
|
|
28
|
+
* @param {number} limit - Number of recent entries to return
|
|
29
|
+
* @returns {Array} - Recent audit log entries
|
|
30
|
+
*/
|
|
31
|
+
function readAuditLogs(limit = 100) {
|
|
32
|
+
const auditPath = path.join(
|
|
33
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
34
|
+
'.multis',
|
|
35
|
+
'audit.log'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(auditPath)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const content = fs.readFileSync(auditPath, 'utf8');
|
|
43
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
44
|
+
|
|
45
|
+
// Parse last N lines
|
|
46
|
+
const recentLines = lines.slice(-limit);
|
|
47
|
+
return recentLines.map(line => JSON.parse(line));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get audit statistics
|
|
52
|
+
* @returns {Object} - Statistics about audit logs
|
|
53
|
+
*/
|
|
54
|
+
function getAuditStats() {
|
|
55
|
+
const logs = readAuditLogs(1000); // Last 1000 entries
|
|
56
|
+
|
|
57
|
+
const stats = {
|
|
58
|
+
total: logs.length,
|
|
59
|
+
byUser: {},
|
|
60
|
+
byCommand: {},
|
|
61
|
+
denied: 0,
|
|
62
|
+
confirmed: 0
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
logs.forEach(log => {
|
|
66
|
+
// Count by user
|
|
67
|
+
if (log.user_id) {
|
|
68
|
+
stats.byUser[log.user_id] = (stats.byUser[log.user_id] || 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Count by command
|
|
72
|
+
if (log.command) {
|
|
73
|
+
const baseCmd = log.command.split(' ')[0];
|
|
74
|
+
stats.byCommand[baseCmd] = (stats.byCommand[baseCmd] || 0) + 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Count denied
|
|
78
|
+
if (log.allowed === false) {
|
|
79
|
+
stats.denied++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Count confirmed
|
|
83
|
+
if (log.confirmed === true) {
|
|
84
|
+
stats.confirmed++;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return stats;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
logAudit,
|
|
93
|
+
readAuditLogs,
|
|
94
|
+
getAuditStats
|
|
95
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load governance configuration
|
|
6
|
+
* @returns {Object} Governance config
|
|
7
|
+
*/
|
|
8
|
+
function loadGovernance() {
|
|
9
|
+
const configPath = path.join(
|
|
10
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
11
|
+
'.multis',
|
|
12
|
+
'governance.json'
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(configPath)) {
|
|
16
|
+
throw new Error('Governance config not found. Run: multis init');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a command is allowed by governance policy
|
|
24
|
+
* @param {string} command - Full command string (e.g., "ls -la ~/Documents")
|
|
25
|
+
* @returns {Object} - { allowed: boolean, reason?: string, requiresConfirmation: boolean }
|
|
26
|
+
*/
|
|
27
|
+
function isCommandAllowed(command) {
|
|
28
|
+
const gov = loadGovernance();
|
|
29
|
+
const parts = command.trim().split(/\s+/);
|
|
30
|
+
const baseCmd = parts[0];
|
|
31
|
+
|
|
32
|
+
// Check denylist first (explicit deny wins)
|
|
33
|
+
if (gov.commands.denylist.includes(baseCmd)) {
|
|
34
|
+
return {
|
|
35
|
+
allowed: false,
|
|
36
|
+
reason: `Command '${baseCmd}' is explicitly denied by governance policy`,
|
|
37
|
+
requiresConfirmation: false
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if requires confirmation
|
|
42
|
+
const needsConfirmation = gov.commands.requireConfirmation.includes(baseCmd);
|
|
43
|
+
|
|
44
|
+
// Check allowlist
|
|
45
|
+
if (gov.commands.allowlist.includes(baseCmd)) {
|
|
46
|
+
return {
|
|
47
|
+
allowed: true,
|
|
48
|
+
requiresConfirmation: needsConfirmation
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Not in allowlist = denied
|
|
53
|
+
return {
|
|
54
|
+
allowed: false,
|
|
55
|
+
reason: `Command '${baseCmd}' is not in the allowlist`,
|
|
56
|
+
requiresConfirmation: false
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a path is allowed by governance policy
|
|
62
|
+
* @param {string} filePath - Path to check
|
|
63
|
+
* @returns {Object} - { allowed: boolean, reason?: string }
|
|
64
|
+
*/
|
|
65
|
+
function isPathAllowed(filePath) {
|
|
66
|
+
const gov = loadGovernance();
|
|
67
|
+
const expandedPath = filePath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
|
|
68
|
+
|
|
69
|
+
// Check denied paths first
|
|
70
|
+
for (const deniedPath of gov.paths.denied) {
|
|
71
|
+
const expandedDenied = deniedPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
|
|
72
|
+
if (expandedPath.startsWith(expandedDenied)) {
|
|
73
|
+
return {
|
|
74
|
+
allowed: false,
|
|
75
|
+
reason: `Path '${filePath}' is in a denied directory`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check allowed paths
|
|
81
|
+
for (const allowedPath of gov.paths.allowed) {
|
|
82
|
+
const expandedAllowed = allowedPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE);
|
|
83
|
+
if (expandedPath.startsWith(expandedAllowed)) {
|
|
84
|
+
return { allowed: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Not in allowed paths = denied
|
|
89
|
+
return {
|
|
90
|
+
allowed: false,
|
|
91
|
+
reason: `Path '${filePath}' is not in an allowed directory`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
loadGovernance,
|
|
97
|
+
isCommandAllowed,
|
|
98
|
+
isPathAllowed
|
|
99
|
+
};
|