memhouse 0.13.0 → 0.15.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/bin/memhouse.js +191 -24
- package/memhouse/capabilities.js +75 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +74 -73
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +36 -8
- package/memhouse/secret-input.js +39 -0
- package/package.json +1 -1
package/bin/memhouse.js
CHANGED
|
@@ -28,6 +28,8 @@ const {
|
|
|
28
28
|
SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
|
|
29
29
|
} = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
|
+
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
32
|
+
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
31
33
|
|
|
32
34
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
33
35
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
@@ -161,6 +163,24 @@ async function ch(cfg, sql, { database = cfg.db, settings = null, timeout = 3000
|
|
|
161
163
|
if (!res.ok) throw new Error(text.trim().split('\n')[0]);
|
|
162
164
|
return text.trim();
|
|
163
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* The grant lines ClickHouse reports for a user, as strings.
|
|
168
|
+
*
|
|
169
|
+
* Never pass a FORMAT here — chRows appends its own, and a statement carrying two is a
|
|
170
|
+
* syntax error. That mistake once read as "no grants at all", which refused every
|
|
171
|
+
* credential including a genuine superuser, and it was invisible on any machine whose
|
|
172
|
+
* own credential is an ordinary member.
|
|
173
|
+
*
|
|
174
|
+
* Unreadable grants come back as [] rather than throwing: a credential that cannot read
|
|
175
|
+
* its own grants is, for every decision made from this, simply not an administrator.
|
|
176
|
+
*/
|
|
177
|
+
async function readGrants(cfg, user) {
|
|
178
|
+
try {
|
|
179
|
+
const rows = await chRows(cfg, `SHOW GRANTS FOR ${user}`, { database: '' });
|
|
180
|
+
return rows.map((r) => Object.values(r).join(' '));
|
|
181
|
+
} catch { return []; }
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
async function chRows(cfg, sql, opts) {
|
|
165
185
|
const text = await ch(cfg, sql + ' FORMAT JSONEachRow', opts);
|
|
166
186
|
return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
|
|
@@ -270,16 +290,20 @@ function askSecret(label, dflt = '') {
|
|
|
270
290
|
const wasRaw = stdin.isRaw;
|
|
271
291
|
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf-8');
|
|
272
292
|
let buf = '';
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (
|
|
281
|
-
if (
|
|
282
|
-
|
|
293
|
+
// One 'data' event carries a CHUNK, not a keystroke — see memhouse/secret-input.js
|
|
294
|
+
// for why that distinction is load-bearing and what it broke.
|
|
295
|
+
let done = false;
|
|
296
|
+
const onData = (chunk) => {
|
|
297
|
+
if (done) return;
|
|
298
|
+
const r = consumeSecretChunk(buf, chunk);
|
|
299
|
+
buf = r.buf;
|
|
300
|
+
if (r.interrupted) { process.stdout.write('\n'); process.exit(130); }
|
|
301
|
+
if (!r.done) return;
|
|
302
|
+
done = true;
|
|
303
|
+
stdin.removeListener('data', onData);
|
|
304
|
+
stdin.setRawMode(!!wasRaw); stdin.pause();
|
|
305
|
+
process.stdout.write('\n');
|
|
306
|
+
resolve(buf || dflt);
|
|
283
307
|
};
|
|
284
308
|
stdin.on('data', onData);
|
|
285
309
|
});
|
|
@@ -325,9 +349,11 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
325
349
|
--env FILE installs from an invite file (see: invite)
|
|
326
350
|
invite <name> mint a member + house on the server and write the env
|
|
327
351
|
file their install needs (--url --admin-user
|
|
328
|
-
--admin-password
|
|
329
|
-
machine untouched. Refuses a house
|
|
330
|
-
someone's messages — --adopt if
|
|
352
|
+
[--admin-password, else prompted] [--db NAME]
|
|
353
|
+
[--out FILE]); local machine untouched. Refuses a house
|
|
354
|
+
that already holds someone's messages — --adopt if
|
|
355
|
+
sharing it is intended. Not an admin? --print-sql gives
|
|
356
|
+
the statements to hand to whoever is
|
|
331
357
|
passwd rotate this member's password + rewrite the env file
|
|
332
358
|
(admin-assisted: --admin-user --admin-password)
|
|
333
359
|
--print-sql print the SQL, run it yourself
|
|
@@ -356,6 +382,8 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
356
382
|
resume <session-id> print the command that reopens that session in its editor
|
|
357
383
|
sessions-query print the session rollup SQL for this credential
|
|
358
384
|
start | stop | shipper loop + dashboard as background daemons
|
|
385
|
+
whoami which credential is in play, and what it may do
|
|
386
|
+
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
359
387
|
status daemons, connection, counts, freshness (--json)
|
|
360
388
|
doctor diagnose the whole pipeline
|
|
361
389
|
|
|
@@ -2911,9 +2939,77 @@ async function cmdNightly() {
|
|
|
2911
2939
|
* transfer, and rotation (memhouse passwd) is printed because the inviter knows this
|
|
2912
2940
|
* password until the invitee changes it.
|
|
2913
2941
|
*/
|
|
2942
|
+
/**
|
|
2943
|
+
* `memhouse whoami [--json] [--admin]`
|
|
2944
|
+
*
|
|
2945
|
+
* Which credential is in play here, and what may it actually do. Exists so nothing has
|
|
2946
|
+
* to hand-roll `SHOW GRANTS` and grep the result — a skill that reasons about privileges
|
|
2947
|
+
* in prose gets it subtly wrong, and the wrong answer is either "you cannot" to an
|
|
2948
|
+
* administrator or "go ahead" to a member who is about to hit ACCESS_DENIED.
|
|
2949
|
+
*
|
|
2950
|
+
* `--admin` resolves MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD first, so an agent
|
|
2951
|
+
* can ask "is there an admin credential in this environment?" without inventing a place
|
|
2952
|
+
* to store one. Nothing here prints or persists a password.
|
|
2953
|
+
*/
|
|
2954
|
+
async function cmdWhoami() {
|
|
2955
|
+
const cfg = resolveConfig();
|
|
2956
|
+
const wantAdmin = flags.admin === true;
|
|
2957
|
+
const au = process.env.MEMHOUSE_ADMIN_USER;
|
|
2958
|
+
const ap = process.env.MEMHOUSE_ADMIN_PASSWORD;
|
|
2959
|
+
const usingAdminEnv = wantAdmin && au;
|
|
2960
|
+
const who = usingAdminEnv ? { ...cfg, user: au, password: ap || '' } : cfg;
|
|
2961
|
+
|
|
2962
|
+
if (!who.url || !who.user) {
|
|
2963
|
+
const msg = 'no credential configured';
|
|
2964
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, reason: msg }, null, 2));
|
|
2965
|
+
else {
|
|
2966
|
+
console.log(bad(`${msg} — nothing in ${short(ENV_FILE)} and no MEMHOUSE_URL/MEMHOUSE_USER set.`));
|
|
2967
|
+
if (wantAdmin) console.log(' For --admin, export MEMHOUSE_ADMIN_USER and MEMHOUSE_ADMIN_PASSWORD.');
|
|
2968
|
+
}
|
|
2969
|
+
return 1;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
let server = null;
|
|
2973
|
+
try { server = (await chRows(who, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
|
|
2974
|
+
catch (e) {
|
|
2975
|
+
const reason = netReason(e);
|
|
2976
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, url: who.url, user: who.user, reason }, null, 2));
|
|
2977
|
+
else console.log(bad(`cannot reach ${who.url} as '${who.user}': ${reason}`));
|
|
2978
|
+
return 1;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
const grants = await readGrants(who, server);
|
|
2982
|
+
const caps = capabilitiesFrom(grants);
|
|
2983
|
+
const source = usingAdminEnv ? 'MEMHOUSE_ADMIN_* environment'
|
|
2984
|
+
: (process.env.MEMHOUSE_USER ? 'MEMHOUSE_* environment' : short(ENV_FILE));
|
|
2985
|
+
const role = caps.isSuperuser ? 'administrator'
|
|
2986
|
+
: caps.canProvision ? 'can provision (users and houses)'
|
|
2987
|
+
: 'member';
|
|
2988
|
+
|
|
2989
|
+
if (JSON_OUT) {
|
|
2990
|
+
console.log(JSON.stringify({
|
|
2991
|
+
ok: true, url: who.url, user: server, db: who.db, source, role, ...caps,
|
|
2992
|
+
grants_readable: grants.length > 0,
|
|
2993
|
+
}, null, 2));
|
|
2994
|
+
return 0;
|
|
2995
|
+
}
|
|
2996
|
+
console.log(ok(`${server} at ${who.url} — ${role}`));
|
|
2997
|
+
console.log(` credential from: ${source}`);
|
|
2998
|
+
console.log(` house: ${who.db || '(none set)'}`);
|
|
2999
|
+
console.log(` may create users: ${caps.canMintUsers ? 'yes' : 'no'}`);
|
|
3000
|
+
console.log(` may create houses: ${caps.canMintHouses ? 'yes' : 'no'}`);
|
|
3001
|
+
console.log(` may read any house: ${caps.canReadEveryHouse ? 'yes' : 'no'}`);
|
|
3002
|
+
if (!grants.length) console.log(warn('could not read its own grants — treating it as a member'));
|
|
3003
|
+
if (!caps.canProvision) {
|
|
3004
|
+
console.log(' Inviting and server-wide administration need an administrator; this is not one.');
|
|
3005
|
+
console.log(' Supply one for this shell: export MEMHOUSE_ADMIN_USER=… MEMHOUSE_ADMIN_PASSWORD=…');
|
|
3006
|
+
}
|
|
3007
|
+
return 0;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2914
3010
|
async function cmdInvite() {
|
|
2915
3011
|
const name = positional[0];
|
|
2916
|
-
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> --admin-user … --admin-password … [--db <house>] [--out <file>] [--adopt]'); return 2; }
|
|
3012
|
+
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
|
|
2917
3013
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
|
|
2918
3014
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
2919
3015
|
const cfg = resolveConfig();
|
|
@@ -2936,27 +3032,97 @@ async function cmdInvite() {
|
|
|
2936
3032
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
|
|
2937
3033
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
2938
3034
|
|
|
3035
|
+
// The way out for someone who is NOT the administrator. Being unable to invite is not a
|
|
3036
|
+
// bug to be worked around — a member holds no CREATE USER by design — but "ask your
|
|
3037
|
+
// admin" is only useful if you can hand them something exact. This prints the same
|
|
3038
|
+
// statements adminBootstrap would run, so the person with the credential runs four
|
|
3039
|
+
// lines instead of installing anything. Nothing is contacted and nothing is written.
|
|
3040
|
+
if (flags['print-sql'] === true) {
|
|
3041
|
+
const password = flags['member-password'] && flags['member-password'] !== true
|
|
3042
|
+
? String(flags['member-password']) : generatePassword();
|
|
3043
|
+
console.log(`-- memhouse: give '${name}' an account and their own house on this ClickHouse.`);
|
|
3044
|
+
console.log(`-- Run as a user with ACCESS MANAGEMENT (a stock 'default' will do).`);
|
|
3045
|
+
console.log(memberSql(db, name, password));
|
|
3046
|
+
console.log('-- Then send them these four lines — they are a credential, so use a channel');
|
|
3047
|
+
console.log('-- you trust (croc, a password manager), not chat:');
|
|
3048
|
+
console.log(`-- MEMHOUSE_URL='${url}'`);
|
|
3049
|
+
console.log(`-- MEMHOUSE_USER='${name}'`);
|
|
3050
|
+
console.log(`-- MEMHOUSE_PASSWORD='${password}'`);
|
|
3051
|
+
console.log(`-- MEMHOUSE_DB='${db}'`);
|
|
3052
|
+
console.log(`-- They install with: memhouse install --url ${url} --user ${name} --password '…' --db ${db}`);
|
|
3053
|
+
return 0;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2939
3056
|
// Whose credential mints the member. Explicit --admin-* wins; otherwise TRY THE
|
|
2940
|
-
// CONFIGURED ONE —
|
|
2941
|
-
//
|
|
2942
|
-
//
|
|
2943
|
-
// read system.users (the proxy for "can it provision").
|
|
3057
|
+
// CONFIGURED ONE — a `deploy --local` house makes its member the superuser
|
|
3058
|
+
// (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
|
|
3059
|
+
// invites with no extra flags.
|
|
2944
3060
|
let adminUser = flags['admin-user'];
|
|
2945
3061
|
let adminPass = flags['admin-password'];
|
|
2946
3062
|
if (!adminUser) {
|
|
2947
3063
|
const c = requireConfig(cfg, 'invite');
|
|
2948
3064
|
if (!c.user) return 1;
|
|
3065
|
+
// Ask what this credential may actually DO, not whether it can look at a table.
|
|
3066
|
+
// The old probe was `SELECT 1 FROM system.users` — but since 0.12.6 every member is
|
|
3067
|
+
// granted SHOW USERS, so every member passed it, was told "it can manage users on
|
|
3068
|
+
// this house", and then died at CREATE DATABASE with a raw ACCESS_DENIED. A probe
|
|
3069
|
+
// that says yes to everyone is worse than no probe: it turns "you are not an admin"
|
|
3070
|
+
// into a confusing failure three steps later.
|
|
3071
|
+
//
|
|
3072
|
+
// chRows appends FORMAT itself, so DO NOT pass one — a statement carrying FORMAT
|
|
3073
|
+
// twice is a syntax error, which the catch below turned into "no grants" and refused
|
|
3074
|
+
// EVERY credential, a real superuser included. Only a clean machine could show that:
|
|
3075
|
+
// this machine's credential is a member either way, so the bug was invisible here.
|
|
3076
|
+
// Reachability FIRST, and reported as itself. Folding it into the grants read makes an
|
|
3077
|
+
// unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
|
|
3078
|
+
// as "you are only a member", which sends the reader after a privilege they already
|
|
3079
|
+
// have. Caught on the testbed, where a wrong --url produced exactly that.
|
|
2949
3080
|
try {
|
|
2950
|
-
await ch({ ...c, url }, 'SELECT 1
|
|
3081
|
+
await ch({ ...c, url }, 'SELECT 1', { database: '' });
|
|
3082
|
+
} catch (e) {
|
|
3083
|
+
console.log(bad(`cannot reach ${url} as '${c.user}': ${netReason(e)}`));
|
|
3084
|
+
console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
|
|
3085
|
+
console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
|
|
3086
|
+
console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
|
|
3087
|
+
console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
|
|
3088
|
+
return 1;
|
|
3089
|
+
}
|
|
3090
|
+
// What this credential may do, judged from its own grants and SCOPE-AWARE — a member
|
|
3091
|
+
// holds CREATE DATABASE inside `ON <their-db>.*`, which mints no new house at all.
|
|
3092
|
+
// Shared with `whoami` so the two can never disagree about who is an administrator.
|
|
3093
|
+
const caps = capabilitiesFrom(await readGrants({ ...c, url }, c.user));
|
|
3094
|
+
if (caps.canProvision) {
|
|
2951
3095
|
adminUser = c.user; adminPass = c.password;
|
|
2952
|
-
console.log(ok(`inviting as your own credential '${c.user}' (it can
|
|
2953
|
-
}
|
|
2954
|
-
console.log(bad('
|
|
2955
|
-
console.log('
|
|
3096
|
+
console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
|
|
3097
|
+
} else {
|
|
3098
|
+
console.log(bad(`'${c.user}' is a MEMBER of this ClickHouse, not an administrator — it cannot create accounts.`));
|
|
3099
|
+
console.log(' Inviting mints a ClickHouse user and a database, which needs CREATE USER and');
|
|
3100
|
+
console.log(' CREATE DATABASE. A member deliberately holds neither: that is the boundary that');
|
|
3101
|
+
console.log(' keeps one housemate out of another house.');
|
|
3102
|
+
console.log('');
|
|
3103
|
+
console.log(' If you RUN this ClickHouse — use the admin credential you created it with');
|
|
3104
|
+
console.log(' (for a stock server that is `default`); memhouse never stores it, so pass it');
|
|
3105
|
+
console.log(' per invite and it is used for one connection and discarded:');
|
|
3106
|
+
console.log(` memhouse invite ${name} --url ${url} --admin-user default`);
|
|
3107
|
+
console.log(' (leave --admin-password off and it is prompted for, so it stays out of');
|
|
3108
|
+
console.log(' your shell history and the process list)');
|
|
3109
|
+
console.log('');
|
|
3110
|
+
console.log(' If SOMEONE ELSE runs it — you cannot invite, and no flag changes that.');
|
|
3111
|
+
console.log(' Print the statements and send them to whoever administers the server:');
|
|
3112
|
+
console.log(` memhouse invite ${name} --url ${url} --print-sql`);
|
|
2956
3113
|
return 1;
|
|
2957
3114
|
}
|
|
2958
3115
|
} else if (adminPass === undefined) {
|
|
2959
|
-
|
|
3116
|
+
// Prompt rather than refuse. A password given as --admin-password lands in the
|
|
3117
|
+
// process list for the life of the request and in shell history unless the caller
|
|
3118
|
+
// remembered a leading space; asking for it keeps it in this process only.
|
|
3119
|
+
// Non-TTY (an agent, CI) still gets the old refusal — there is nobody to ask.
|
|
3120
|
+
if (!process.stdin.isTTY) {
|
|
3121
|
+
console.log(bad('--admin-user given without --admin-password (no TTY to prompt on)'));
|
|
3122
|
+
return 1;
|
|
3123
|
+
}
|
|
3124
|
+
adminPass = await askSecret(` password for '${adminUser}'`);
|
|
3125
|
+
if (!adminPass) { console.log(bad('no password given')); return 1; }
|
|
2960
3126
|
}
|
|
2961
3127
|
|
|
2962
3128
|
// WHERE to connect to provision. --url is the address the INVITEE will use — it may be
|
|
@@ -3358,6 +3524,7 @@ async function cmdUninstall() {
|
|
|
3358
3524
|
}
|
|
3359
3525
|
break;
|
|
3360
3526
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
3527
|
+
case 'whoami': process.exitCode = await cmdWhoami(); break;
|
|
3361
3528
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
3362
3529
|
case 'passwd': process.exitCode = await cmdPasswd(); break;
|
|
3363
3530
|
case 'nightly': process.exitCode = await cmdNightly(); break;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What a ClickHouse credential may actually DO, read from its own grants.
|
|
2
|
+
//
|
|
3
|
+
// This exists because asking the wrong question here is expensive and quiet. `invite`
|
|
4
|
+
// used to probe with `SELECT 1 FROM system.users`: since 0.12.6 every member holds
|
|
5
|
+
// SHOW USERS, so every member passed, was told it could manage users, and then died at
|
|
6
|
+
// CREATE DATABASE with a raw ACCESS_DENIED. The replacement must be exact, and it must
|
|
7
|
+
// live in one place — invite and /mem:admin disagreeing about who is an administrator is
|
|
8
|
+
// the same class of bug wearing a different hat.
|
|
9
|
+
//
|
|
10
|
+
// SCOPE IS THE WHOLE POINT. A grant is `GRANT <privileges> ON <scope> TO <user>`, and a
|
|
11
|
+
// privilege only means what its scope allows: an ordinary member holds CREATE DATABASE
|
|
12
|
+
// inside `ON polat.*`, which lets them do nothing whatsoever about creating a NEW
|
|
13
|
+
// database. Matching the privilege name anywhere in the text — which an earlier draft of
|
|
14
|
+
// this did — reads that member as a server administrator. So parse the line, keep the
|
|
15
|
+
// scope, and only count a privilege that is granted server-wide.
|
|
16
|
+
|
|
17
|
+
/** `GRANT a, b, c ON scope TO user [WITH GRANT OPTION]` -> {privs:[…], scope} | null */
|
|
18
|
+
function parseGrantLine(line) {
|
|
19
|
+
const m = /^\s*GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s+/i.exec(String(line || ''));
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
const privs = m[1].split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
22
|
+
return { privs, scope: m[2] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Server-wide scope: `*.*` (tables everywhere) or `*` (the access/user namespace). */
|
|
26
|
+
function isServerWide(scope) {
|
|
27
|
+
return scope === '*.*' || scope === '*';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasPriv(privs, name) {
|
|
31
|
+
return privs.includes(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive capabilities from the lines `SHOW GRANTS FOR <user>` returned.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]|string} grants one string per grant line, or the whole blob
|
|
38
|
+
* @returns {{canMintUsers:boolean, canMintHouses:boolean, canProvision:boolean,
|
|
39
|
+
* canReadEveryHouse:boolean, canSeeUsers:boolean, isSuperuser:boolean}}
|
|
40
|
+
*/
|
|
41
|
+
function capabilitiesFrom(grants) {
|
|
42
|
+
const lines = Array.isArray(grants) ? grants : String(grants || '').split('\n');
|
|
43
|
+
let canMintUsers = false;
|
|
44
|
+
let canMintHouses = false;
|
|
45
|
+
let canReadEveryHouse = false;
|
|
46
|
+
let canSeeUsers = false;
|
|
47
|
+
|
|
48
|
+
for (const raw of lines) {
|
|
49
|
+
const g = parseGrantLine(raw);
|
|
50
|
+
if (!g) continue;
|
|
51
|
+
const wide = isServerWide(g.scope);
|
|
52
|
+
// SHOW USERS is the one every member has — never evidence of anything.
|
|
53
|
+
if (hasPriv(g.privs, 'SHOW USERS')) canSeeUsers = true;
|
|
54
|
+
if (!wide) continue;
|
|
55
|
+
// Minting accounts. ACCESS MANAGEMENT is the umbrella; CREATE USER the specific.
|
|
56
|
+
if (hasPriv(g.privs, 'ACCESS MANAGEMENT') || hasPriv(g.privs, 'CREATE USER')) canMintUsers = true;
|
|
57
|
+
// Minting houses. `CREATE DATABASE` is the specific; bare `CREATE` is the umbrella
|
|
58
|
+
// ClickHouse hands a superuser (what `deploy --local` grants), and `ALL` covers both.
|
|
59
|
+
if (hasPriv(g.privs, 'CREATE DATABASE') || hasPriv(g.privs, 'CREATE') || hasPriv(g.privs, 'ALL')) canMintHouses = true;
|
|
60
|
+
// Reading anyone's rooms — what separates an administrator's view from a member's.
|
|
61
|
+
if (hasPriv(g.privs, 'SELECT') || hasPriv(g.privs, 'ALL')) canReadEveryHouse = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const canProvision = canMintUsers && canMintHouses;
|
|
65
|
+
return {
|
|
66
|
+
canMintUsers,
|
|
67
|
+
canMintHouses,
|
|
68
|
+
canProvision,
|
|
69
|
+
canReadEveryHouse,
|
|
70
|
+
canSeeUsers,
|
|
71
|
+
isSuperuser: canProvision && canReadEveryHouse,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { capabilitiesFrom, parseGrantLine, isServerWide };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: ask, search, browse, analyze, check, share, and invite across all 17 supported editors and machines, plus administration of the ClickHouse behind it. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, /mem:users, and /mem:admin.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: admin
|
|
3
|
-
description: Operate the memhouse ClickHouse as its administrator — list and inspect every user and house, provision or remove members, grant and revoke access, and read server-wide health (disk, parts, mutations, running queries). Use when the user asks to "do X as admin", "manage users", "drop a house", "see disk usage", "who is on this server", or when a member-scoped skill refused for want of privileges. Requires an
|
|
3
|
+
description: Operate the memhouse ClickHouse as its administrator — list and inspect every user and house, provision or remove members, grant and revoke access, and read server-wide health (disk, parts, mutations, running queries). Use when the user asks to "do X as admin", "manage users", "drop a house", "see disk usage", "who is on this server", or when a member-scoped skill refused for want of privileges. Requires an administrator credential in the environment; says so plainly, and how to supply one, when there is not.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "<what you want done>"
|
|
6
6
|
allowed-tools: Bash
|
|
@@ -12,61 +12,61 @@ A member owns one database. An **administrator** owns the ClickHouse: every hous
|
|
|
12
12
|
account, every grant. This skill is the second one, and it is the only skill allowed to
|
|
13
13
|
act outside the caller's own house.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
**An admin password must never enter this conversation.** memhouse ships this transcript
|
|
16
|
+
into the house you are administering, so a password pasted here is a password published
|
|
17
|
+
to the archive — and unlike a file you can delete, the archive is insert-only. Resolve
|
|
18
|
+
the credential from the environment or refuse. Never ask for one in chat, never echo one,
|
|
19
|
+
never write one to a file.
|
|
19
20
|
|
|
20
|
-
## 1.
|
|
21
|
-
|
|
22
|
-
Try these in order and stop at the first that works. **Never print any of these files or
|
|
23
|
-
the variables in them** — no `cat`, no `env | grep`, no `set -x`.
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
MH_HOME="${MEMHOUSE_HOME:-$HOME/.memhouse}"
|
|
27
|
-
# a) an explicit admin credential in the environment
|
|
28
|
-
ADM_U=${MEMHOUSE_ADMIN_USER-}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD-}
|
|
29
|
-
# b) an admin env file kept beside the member config
|
|
30
|
-
if [ -z "$ADM_U" ] && [ -f "$MH_HOME/admin.env" ]; then
|
|
31
|
-
set -a; . "$MH_HOME/admin.env"; set +a
|
|
32
|
-
ADM_U=${MEMHOUSE_ADMIN_USER:-${MEMHOUSE_USER-}}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD:-${MEMHOUSE_PASSWORD-}}
|
|
33
|
-
fi
|
|
34
|
-
# c) the ordinary member credential — on a house the pilot stood up themselves
|
|
35
|
-
# (memhouse deploy --local) the member IS the superuser
|
|
36
|
-
set -a; [ -f "$MH_HOME/env" ] && . "$MH_HOME/env"; set +a
|
|
37
|
-
: "${ADM_U:=${MEMHOUSE_USER-}}"; : "${ADM_P:=${MEMHOUSE_PASSWORD-}}"
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Then **prove** it is actually an admin rather than assuming — a member can often read
|
|
41
|
-
`system.users` while holding nothing else:
|
|
21
|
+
## 1. Ask the CLI what you are holding — do not work it out yourself
|
|
42
22
|
|
|
43
23
|
```bash
|
|
44
|
-
|
|
45
|
-
--data-binary "SHOW GRANTS FOR $ADM_U FORMAT TSV" "$MEMHOUSE_URL/"
|
|
24
|
+
memhouse whoami --admin --json
|
|
46
25
|
```
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
27
|
+
That resolves `MEMHOUSE_ADMIN_USER` / `MEMHOUSE_ADMIN_PASSWORD` when they are set and
|
|
28
|
+
falls back to the configured credential, then reports what it may actually do. Read
|
|
29
|
+
`role` and the capability flags; never infer privileges from a grant string yourself.
|
|
30
|
+
The command prints no password.
|
|
31
|
+
|
|
32
|
+
- **`"canProvision": true`** (or `"role": "administrator"`) — you have an administrator.
|
|
33
|
+
Go to §2.
|
|
34
|
+
- **`"canProvision": false`** — the credential is a member. **Stop.** Do not retry, do
|
|
35
|
+
not look for a stored password, and do not ask for one. Tell the user:
|
|
36
|
+
|
|
37
|
+
> `/mem:admin` needs an administrator credential for `<url>`, and what is configured
|
|
38
|
+
> here is the member account `<user>` — it owns its own house and nothing else. If you
|
|
39
|
+
> administer that ClickHouse, put the credential in this shell's environment and ask
|
|
40
|
+
> again:
|
|
41
|
+
>
|
|
42
|
+
> ```
|
|
43
|
+
> export MEMHOUSE_ADMIN_USER=default
|
|
44
|
+
> read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD
|
|
45
|
+
> ```
|
|
46
|
+
>
|
|
47
|
+
> `read -rs` keeps it off the screen and out of your shell history. It lives only in
|
|
48
|
+
> that shell — nothing is written to disk, and memhouse never stores it.
|
|
49
|
+
>
|
|
50
|
+
> If somebody else administers the server, this is theirs to run, not yours.
|
|
51
|
+
|
|
52
|
+
- **`"ok": false`** — a connection problem, not a privilege one. Report `reason` as
|
|
53
|
+
given; do not translate it into "you are not an admin".
|
|
54
|
+
|
|
55
|
+
**Never suggest storing the credential** — not in `~/.memhouse/env`, not in an
|
|
56
|
+
`admin.env`, not anywhere on disk. An admin credential owns the whole server, and every
|
|
57
|
+
process running as this user can read a file. The environment of one shell is the whole
|
|
58
|
+
supported surface. (A house from `memhouse deploy --local` is the exception that needs
|
|
59
|
+
nothing: its member *is* the superuser, and `whoami` will already say so.)
|
|
60
|
+
|
|
61
|
+
## 2. Do the work
|
|
62
|
+
|
|
63
|
+
Use the same environment for every query. `--fail-with-body` so ClickHouse's own error is
|
|
64
|
+
visible; `&readonly=1` on anything that only reads.
|
|
67
65
|
|
|
68
66
|
```bash
|
|
69
|
-
adm() { curl -sS --fail-with-body
|
|
67
|
+
adm() { curl -sS --fail-with-body \
|
|
68
|
+
--user "$MEMHOUSE_ADMIN_USER:$MEMHOUSE_ADMIN_PASSWORD" \
|
|
69
|
+
--data-binary "$1" "$MEMHOUSE_URL/${2-}"; }
|
|
70
70
|
adm "SELECT … FORMAT PrettyCompact" "?readonly=1"
|
|
71
71
|
```
|
|
72
72
|
|
|
@@ -74,21 +74,19 @@ Reads worth knowing — reach for these before inventing SQL:
|
|
|
74
74
|
|
|
75
75
|
| Question | Query |
|
|
76
76
|
|---|---|
|
|
77
|
-
| who exists | `SELECT name FROM system.users ORDER BY name` |
|
|
78
|
-
| what
|
|
77
|
+
| who exists | `SELECT name, storage FROM system.users ORDER BY name` |
|
|
78
|
+
| what someone may do | `SHOW GRANTS FOR <user>` |
|
|
79
79
|
| every house and its size | `SELECT database, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active GROUP BY database ORDER BY sum(bytes_on_disk) DESC` |
|
|
80
80
|
| a house's rooms | `SELECT table, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active AND database = '<db>' GROUP BY table` |
|
|
81
81
|
| who writes where | `SELECT user_id, host, count() FROM <db>.messages GROUP BY user_id, host` |
|
|
82
82
|
| running queries | `SELECT query_id, user, elapsed, formatReadableSize(memory_usage) AS mem, substring(query,1,120) AS q FROM system.processes` |
|
|
83
83
|
| unfinished mutations | `SELECT database, table, mutation_id, command, parts_to_do, latest_fail_reason FROM system.mutations WHERE NOT is_done` |
|
|
84
|
-
| merge backlog | `SELECT database, table, elapsed, progress FROM system.merges` |
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
it does not cover.
|
|
85
|
+
**Prefer a CLI verb over raw SQL wherever one exists.** `memhouse invite <name> --url …`
|
|
86
|
+
provisions a member correctly — right grants, right pin, refuses a house that already
|
|
87
|
+
holds someone's messages — and it takes `--admin-user`/`--admin-password`, so it works
|
|
88
|
+
from this environment. Hand-written `CREATE USER` skips every one of those guards. This
|
|
89
|
+
skill is for what no verb covers.
|
|
92
90
|
|
|
93
91
|
## 3. Destructive work — look first, then confirm
|
|
94
92
|
|
|
@@ -99,30 +97,33 @@ editors that produced them.
|
|
|
99
97
|
|
|
100
98
|
Before any of them:
|
|
101
99
|
|
|
102
|
-
1. **Look at the target and report what is
|
|
100
|
+
1. **Look at the target and report what is really there** — row counts per room, who
|
|
103
101
|
wrote them, the date span, which machines. A house you expected to be empty may not
|
|
104
|
-
be
|
|
102
|
+
be: one `ege` house looked disposable and held 3,733 messages belonging to somebody
|
|
103
|
+
else entirely.
|
|
105
104
|
2. **Name the collateral.** Dropping a user stops that account's shipper on every machine
|
|
106
|
-
it runs on. Revoking a grant closes a share
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
it runs on. Revoking a grant closes a share someone may be mid-query on. Rotating a
|
|
106
|
+
password breaks every machine shipping under it — and grants are per-user while the
|
|
107
|
+
fleet is per-`user@host`, so there is no way to revoke one machine.
|
|
108
|
+
3. **Get an explicit yes for that specific object**, quoting the counts you just read. A
|
|
109
|
+
general "yes, do admin things" is not consent to drop a populated house.
|
|
110
|
+
4. **Verify afterwards** and say what is now true, including what was destroyed.
|
|
111
111
|
|
|
112
112
|
If the user has already told you exactly what to destroy and why, and the counts match
|
|
113
|
-
their description, proceed — do not re-litigate a decision they have made.
|
|
114
|
-
outcome plainly, including the row counts destroyed.
|
|
113
|
+
their description, proceed — do not re-litigate a decision they have made.
|
|
115
114
|
|
|
116
115
|
## Never
|
|
117
116
|
|
|
118
117
|
Never accept an admin password typed into the conversation, and never print one — not in
|
|
119
|
-
a command, not in an echo, not in an error.
|
|
120
|
-
|
|
118
|
+
a command, not in an echo, not in an error. Commands expand `"$MEMHOUSE_ADMIN_PASSWORD"`;
|
|
119
|
+
the value never appears as literal text.
|
|
120
|
+
|
|
121
|
+
Never recommend writing an admin credential to disk.
|
|
121
122
|
|
|
122
|
-
Never widen a member's grants beyond what `memhouse invite` issues
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
Never widen a member's grants beyond what `memhouse invite` issues unless the user asks
|
|
124
|
+
for that specific privilege and has been told what it allows. Never grant `ACCESS
|
|
125
|
+
MANAGEMENT`, `CREATE USER`, or anything at `*.*` casually — those mint a second
|
|
125
126
|
administrator.
|
|
126
127
|
|
|
127
|
-
Never touch a house that is not the subject of the request
|
|
128
|
+
Never touch a house that is not the subject of the request, and never run a destructive
|
|
128
129
|
statement whose predicate you have not first run as a `SELECT`.
|
|
@@ -42,14 +42,42 @@ substituted into a command — that burns the credential into the transcript on
|
|
|
42
42
|
If it prints `invite written: …`, go to step 4 — you are done, and no admin secret
|
|
43
43
|
ever touched this conversation.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
Otherwise read WHICH refusal came back; they need different answers.
|
|
46
|
+
|
|
47
|
+
**"cannot reach `<url>` …"** — nothing to do with privileges. The address is wrong,
|
|
48
|
+
or the house is bound to loopback (`memhouse deploy --local` binds to 127.0.0.1 on
|
|
49
|
+
purpose, so no LAN address reaches it and no invitee could either). Ask the user for
|
|
50
|
+
the address the invitee will actually use; do not retry with a guess.
|
|
51
|
+
|
|
52
|
+
**"`<user>` is a MEMBER of this ClickHouse, not an administrator"** — inviting needs
|
|
53
|
+
CREATE USER and CREATE DATABASE, which a member deliberately does not hold. Two cases,
|
|
54
|
+
and you must ask which one applies rather than assuming:
|
|
55
|
+
|
|
56
|
+
- **They run the ClickHouse.** They have the admin credential; memhouse never stores
|
|
57
|
+
it. Do NOT take that password in chat — memhouse archives this transcript. Hand them
|
|
58
|
+
the line to run THEMSELVES, and leave `--admin-password` OFF so it is prompted for,
|
|
59
|
+
keeping it out of both the process list and shell history:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
memhouse invite <name> --url <url> [--db <shared-house>] --admin-user <admin>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**You cannot run that line yourself.** The prompt needs a TTY and your Bash tool has
|
|
66
|
+
none, so it refuses with "no TTY to prompt on" — and the fix is NOT to add
|
|
67
|
+
`--admin-password`, which would put the credential in this transcript. The user runs
|
|
68
|
+
it in their own terminal; you wait for them to say it is done.
|
|
69
|
+
|
|
70
|
+
- **Somebody else runs it.** They cannot invite, and no flag changes that. Print the
|
|
71
|
+
statements for whoever administers the server — this contacts nothing and needs no
|
|
72
|
+
credential:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
memhouse invite <name> --url <url> [--db <shared-house>] --print-sql
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The output ends with the four `MEMHOUSE_*` lines the invitee needs. Those lines are
|
|
79
|
+
a credential: tell the user to send them over a trusted channel, and **never print
|
|
80
|
+
them into the conversation yourself**.
|
|
53
81
|
|
|
54
82
|
**If it refuses with "house `<db>` already exists and holds N messages", STOP.** That
|
|
55
83
|
house is somebody's memory, and inviting into it would hand the invitee someone else's
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Consuming a hidden-input chunk, as pure logic so it can be tested without a terminal.
|
|
2
|
+
//
|
|
3
|
+
// The subtlety this exists for: a raw-mode stdin 'data' event carries a CHUNK, not a
|
|
4
|
+
// keystroke. While someone types, each chunk happens to be one character, so a handler
|
|
5
|
+
// that compares the whole chunk against '\r' looks correct for as long as anyone tests it
|
|
6
|
+
// by hand. The moment a password is PASTED — which is how a credential out of a password
|
|
7
|
+
// manager actually arrives — the chunk is 'hunter2\r', equal to no terminator, and the
|
|
8
|
+
// prompt hangs forever with the secret sitting in the buffer. Anything piping input hits
|
|
9
|
+
// the same wall.
|
|
10
|
+
//
|
|
11
|
+
// So: walk the chunk. Callers own the terminal; this owns the decision.
|
|
12
|
+
|
|
13
|
+
const ENTER = ['\r', '\n', '\u0004']; // CR, LF, EOT
|
|
14
|
+
const INTERRUPT = '\u0003'; // ctrl-C
|
|
15
|
+
const ERASE = ['\u007f', '\b']; // DEL, BS
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fold one chunk into the accumulated buffer.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} buf what has been read so far
|
|
21
|
+
* @param {string} chunk the raw 'data' payload (may hold any number of characters)
|
|
22
|
+
* @returns {{buf: string, done: boolean, interrupted: boolean}}
|
|
23
|
+
* `done` — a terminator was seen; `buf` is the final value and anything after the
|
|
24
|
+
* terminator in the same chunk is deliberately discarded (it belongs to whatever
|
|
25
|
+
* prompt comes next, not this one).
|
|
26
|
+
* `interrupted` — ctrl-C was seen; the caller decides how to exit.
|
|
27
|
+
*/
|
|
28
|
+
function consumeSecretChunk(buf, chunk) {
|
|
29
|
+
let out = String(buf == null ? '' : buf);
|
|
30
|
+
for (const ch of String(chunk == null ? '' : chunk)) {
|
|
31
|
+
if (ch === INTERRUPT) return { buf: out, done: false, interrupted: true };
|
|
32
|
+
if (ENTER.includes(ch)) return { buf: out, done: true, interrupted: false };
|
|
33
|
+
if (ERASE.includes(ch)) { out = out.slice(0, -1); continue; }
|
|
34
|
+
out += ch;
|
|
35
|
+
}
|
|
36
|
+
return { buf: out, done: false, interrupted: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { consumeSecretChunk };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
|
|
5
5
|
"main": "bin/memhouse.js",
|
|
6
6
|
"bin": {
|