@moxxy/plugin-vault 0.0.33
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/LICENSE +21 -0
- package/dist/crypto.d.ts +30 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +87 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +212 -0
- package/dist/index.js.map +1 -0
- package/dist/keysource.d.ts +50 -0
- package/dist/keysource.d.ts.map +1 -0
- package/dist/keysource.js +145 -0
- package/dist/keysource.js.map +1 -0
- package/dist/placeholder.d.ts +10 -0
- package/dist/placeholder.d.ts.map +1 -0
- package/dist/placeholder.js +92 -0
- package/dist/placeholder.js.map +1 -0
- package/dist/store.d.ts +106 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +415 -0
- package/dist/store.js.map +1 -0
- package/package.json +65 -0
- package/src/command.test.ts +101 -0
- package/src/crypto.test.ts +66 -0
- package/src/crypto.ts +106 -0
- package/src/index.ts +269 -0
- package/src/keysource.test.ts +285 -0
- package/src/keysource.ts +198 -0
- package/src/placeholder.test.ts +142 -0
- package/src/placeholder.ts +104 -0
- package/src/store.canary.test.ts +151 -0
- package/src/store.test.ts +283 -0
- package/src/store.ts +457 -0
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-vault",
|
|
3
|
+
"version": "0.0.33",
|
|
4
|
+
"description": "Encrypted secret storage for moxxy. AES-256-GCM at rest. Master key from OS keychain (@napi-rs/keyring) or passphrase fallback.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"vault",
|
|
8
|
+
"secrets",
|
|
9
|
+
"encryption",
|
|
10
|
+
"plugin"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-vault"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "tools"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"zod": "^3.24.0",
|
|
47
|
+
"@moxxy/sdk": "0.21.1"
|
|
48
|
+
},
|
|
49
|
+
"optionalDependencies": {
|
|
50
|
+
"@napi-rs/keyring": "^1.3.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"typescript": "^5.7.3",
|
|
55
|
+
"vitest": "^2.1.8",
|
|
56
|
+
"@moxxy/vitest-preset": "0.0.0",
|
|
57
|
+
"@moxxy/tsconfig": "0.0.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.json",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"clean": "rm -rf dist .turbo"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { asSessionId, type CommandDef, type EmittedEvent } from '@moxxy/sdk';
|
|
6
|
+
import { buildVaultPlugin } from './index.js';
|
|
7
|
+
import { createStaticKeySource } from './keysource.js';
|
|
8
|
+
import { deriveKey, generateSalt } from './crypto.js';
|
|
9
|
+
|
|
10
|
+
const SECRET = 'sk-super-secret-9999';
|
|
11
|
+
const stableKey = deriveKey('test-passphrase', generateSalt());
|
|
12
|
+
|
|
13
|
+
let tmp: string;
|
|
14
|
+
let filePath: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-vault-cmd-'));
|
|
18
|
+
filePath = path.join(tmp, 'vault.json');
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function setup() {
|
|
25
|
+
const { plugin, vault } = buildVaultPlugin({
|
|
26
|
+
filePath,
|
|
27
|
+
keySource: createStaticKeySource(stableKey),
|
|
28
|
+
});
|
|
29
|
+
const cmd = plugin.commands?.find((c) => c.name === 'vault') as CommandDef;
|
|
30
|
+
const appended: EmittedEvent[] = [];
|
|
31
|
+
const session = {
|
|
32
|
+
startTurn: () => ({ turnId: 'turn-1' }),
|
|
33
|
+
log: {
|
|
34
|
+
append: async (e: EmittedEvent) => {
|
|
35
|
+
appended.push(e);
|
|
36
|
+
return e as never;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const run = (args: string) =>
|
|
41
|
+
cmd.handler({ channel: 'tui', sessionId: asSessionId('s1'), args, session });
|
|
42
|
+
return { vault, cmd, appended, run };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('/vault command', () => {
|
|
46
|
+
it('is registered with name "vault"', () => {
|
|
47
|
+
const { cmd } = setup();
|
|
48
|
+
expect(cmd).toBeDefined();
|
|
49
|
+
expect(cmd.name).toBe('vault');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('stores the secret and returns only a reference (never the plaintext)', async () => {
|
|
53
|
+
const { vault, appended, run } = setup();
|
|
54
|
+
const out = await run(`set API_KEY ${SECRET}`);
|
|
55
|
+
|
|
56
|
+
// Secret actually stored
|
|
57
|
+
expect(await vault.get('API_KEY')).toBe(SECRET);
|
|
58
|
+
|
|
59
|
+
// User-facing confirmation has the reference, not the value
|
|
60
|
+
expect(out.kind).toBe('text');
|
|
61
|
+
if (out.kind !== 'text') throw new Error('expected text');
|
|
62
|
+
expect(out.text).toContain('${vault:API_KEY}');
|
|
63
|
+
expect(out.text).not.toContain(SECRET);
|
|
64
|
+
|
|
65
|
+
// Model-facing note injected into the log has the reference, not the value
|
|
66
|
+
expect(appended).toHaveLength(1);
|
|
67
|
+
const note = appended[0]!;
|
|
68
|
+
expect(note.type).toBe('user_prompt');
|
|
69
|
+
if (note.type !== 'user_prompt') throw new Error('expected user_prompt');
|
|
70
|
+
expect(note.text).toContain('${vault:API_KEY}');
|
|
71
|
+
expect(note.text).not.toContain(SECRET);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('list shows names + references but never values', async () => {
|
|
75
|
+
const { vault, run } = setup();
|
|
76
|
+
await vault.set('API_KEY', SECRET);
|
|
77
|
+
const out = await run('list');
|
|
78
|
+
expect(out.kind).toBe('text');
|
|
79
|
+
if (out.kind !== 'text') throw new Error('expected text');
|
|
80
|
+
expect(out.text).toContain('API_KEY');
|
|
81
|
+
expect(out.text).toContain('${vault:API_KEY}');
|
|
82
|
+
expect(out.text).not.toContain(SECRET);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects malformed set invocations without storing', async () => {
|
|
86
|
+
const { run, appended } = setup();
|
|
87
|
+
const noValue = await run('set API_KEY');
|
|
88
|
+
expect(noValue.kind).toBe('error');
|
|
89
|
+
const badName = await run('set "bad name" value');
|
|
90
|
+
expect(badName.kind).toBe('error');
|
|
91
|
+
expect(appended).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('shows usage for bare or help invocation', async () => {
|
|
95
|
+
const { run } = setup();
|
|
96
|
+
const out = await run('');
|
|
97
|
+
expect(out.kind).toBe('text');
|
|
98
|
+
if (out.kind !== 'text') throw new Error('expected text');
|
|
99
|
+
expect(out.text).toContain('/vault set');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decrypt, deriveKey, encrypt, generateSalt, randomCode } from './crypto.js';
|
|
3
|
+
|
|
4
|
+
describe('crypto primitives', () => {
|
|
5
|
+
it('round-trips plaintext through encrypt/decrypt', () => {
|
|
6
|
+
const salt = generateSalt();
|
|
7
|
+
const key = deriveKey('hunter2', salt);
|
|
8
|
+
const blob = encrypt('secret value', key);
|
|
9
|
+
expect(decrypt(blob, key)).toBe('secret value');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('fails to decrypt with wrong key', () => {
|
|
13
|
+
const salt = generateSalt();
|
|
14
|
+
const key1 = deriveKey('one', salt);
|
|
15
|
+
const key2 = deriveKey('two', salt);
|
|
16
|
+
const blob = encrypt('secret', key1);
|
|
17
|
+
expect(() => decrypt(blob, key2)).toThrow();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('produces different ciphertext for the same plaintext', () => {
|
|
21
|
+
const key = deriveKey('p', generateSalt());
|
|
22
|
+
const a = encrypt('same', key);
|
|
23
|
+
const b = encrypt('same', key);
|
|
24
|
+
expect(a.data).not.toBe(b.data);
|
|
25
|
+
expect(a.iv).not.toBe(b.iv);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('randomCode produces zero-padded fixed-length digit strings', () => {
|
|
29
|
+
for (let i = 0; i < 20; i++) {
|
|
30
|
+
const code = randomCode(6);
|
|
31
|
+
expect(code).toMatch(/^\d{6}$/);
|
|
32
|
+
}
|
|
33
|
+
expect(randomCode(4)).toMatch(/^\d{4}$/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('randomCode does not cap the leading digit for wide codes (u114-3)', () => {
|
|
37
|
+
// The old 4-byte draw overflowed for digits >= 10, so the leading digits
|
|
38
|
+
// were always '0'. Confirm a 12-digit code can produce a non-zero lead.
|
|
39
|
+
const leads = new Set<string>();
|
|
40
|
+
for (let i = 0; i < 200; i++) {
|
|
41
|
+
const code = randomCode(12);
|
|
42
|
+
expect(code).toMatch(/^\d{12}$/);
|
|
43
|
+
leads.add(code[0]!);
|
|
44
|
+
}
|
|
45
|
+
// The old uint32 draw pinned every leading digit to '0' for digits >= 10.
|
|
46
|
+
expect([...leads].some((d) => d !== '0')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('randomCode is not grossly biased at 6 digits (u114-3)', () => {
|
|
50
|
+
// Chi-square-lite: the leading digit should spread across 0-9, not cluster.
|
|
51
|
+
const counts = new Array(10).fill(0) as number[];
|
|
52
|
+
for (let i = 0; i < 2000; i++) {
|
|
53
|
+
counts[Number(randomCode(6)[0])]! += 1;
|
|
54
|
+
}
|
|
55
|
+
for (const c of counts) {
|
|
56
|
+
// Expected ~200 per bucket; a wide tolerance still catches a stuck/biased draw.
|
|
57
|
+
expect(c).toBeGreaterThan(80);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('randomCode rejects invalid widths', () => {
|
|
62
|
+
expect(() => randomCode(0)).toThrow();
|
|
63
|
+
expect(() => randomCode(-1)).toThrow();
|
|
64
|
+
expect(() => randomCode(1.5)).toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt, scryptSync } from 'node:crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
export interface EncryptedBlob {
|
|
5
|
+
readonly iv: string;
|
|
6
|
+
readonly tag: string;
|
|
7
|
+
readonly data: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const KEY_BYTES = 32;
|
|
11
|
+
const IV_BYTES = 12;
|
|
12
|
+
const SCRYPT_N = 16384;
|
|
13
|
+
const SCRYPT_R = 8;
|
|
14
|
+
const SCRYPT_P = 1;
|
|
15
|
+
|
|
16
|
+
const SCRYPT_OPTS = {
|
|
17
|
+
N: SCRYPT_N,
|
|
18
|
+
r: SCRYPT_R,
|
|
19
|
+
p: SCRYPT_P,
|
|
20
|
+
// scrypt's internal buffer is ~128 * N * r bytes (~16 MiB here). Node's
|
|
21
|
+
// default maxmem (32 MiB) is enough today, but pin it explicitly so a future
|
|
22
|
+
// parameter bump can't silently start throwing "memory limit exceeded".
|
|
23
|
+
maxmem: 64 * 1024 * 1024,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
|
27
|
+
return scryptSync(passphrase, salt, KEY_BYTES, SCRYPT_OPTS);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scryptAsync = promisify(scrypt) as (
|
|
31
|
+
password: string,
|
|
32
|
+
salt: Buffer,
|
|
33
|
+
keylen: number,
|
|
34
|
+
options: typeof SCRYPT_OPTS,
|
|
35
|
+
) => Promise<Buffer>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Off-thread KDF. scrypt (N=16384) is a CPU-bound stall of ~tens of ms;
|
|
39
|
+
* `scryptSync` blocks the single-threaded runner event loop for that whole
|
|
40
|
+
* window, while this runs on libuv's threadpool and yields. Functionally
|
|
41
|
+
* identical to {@link deriveKey} (same params, same output). Used on the
|
|
42
|
+
* runtime key-resolution hot paths; the sync variant stays for callers that
|
|
43
|
+
* derive a key eagerly to construct a static key source.
|
|
44
|
+
*/
|
|
45
|
+
export function deriveKeyAsync(passphrase: string, salt: Buffer): Promise<Buffer> {
|
|
46
|
+
return scryptAsync(passphrase, salt, KEY_BYTES, SCRYPT_OPTS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function generateSalt(): Buffer {
|
|
50
|
+
return randomBytes(16);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function encrypt(plaintext: string, key: Buffer): EncryptedBlob {
|
|
54
|
+
const iv = randomBytes(IV_BYTES);
|
|
55
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
56
|
+
const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
57
|
+
const tag = cipher.getAuthTag();
|
|
58
|
+
return {
|
|
59
|
+
iv: iv.toString('base64'),
|
|
60
|
+
tag: tag.toString('base64'),
|
|
61
|
+
data: data.toString('base64'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function decrypt(blob: EncryptedBlob, key: Buffer): string {
|
|
66
|
+
const iv = Buffer.from(blob.iv, 'base64');
|
|
67
|
+
const tag = Buffer.from(blob.tag, 'base64');
|
|
68
|
+
const data = Buffer.from(blob.data, 'base64');
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
decipher.setAuthTag(tag);
|
|
71
|
+
const plaintext = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
72
|
+
return plaintext.toString('utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A uniformly-random decimal string of exactly `digits` digits (zero-padded).
|
|
77
|
+
*
|
|
78
|
+
* Draws enough entropy for the requested width (not a fixed 4 bytes, which
|
|
79
|
+
* capped any code wider than ~9 digits — 10**digits overflowed a uint32, so
|
|
80
|
+
* the leading digits were always 0) and rejection-samples to strip the
|
|
81
|
+
* modulo bias that `% 10**digits` introduces when the byte range isn't an
|
|
82
|
+
* exact multiple of the modulus. Used for short pairing codes today; correct
|
|
83
|
+
* for any width.
|
|
84
|
+
*/
|
|
85
|
+
export function randomCode(digits = 6): string {
|
|
86
|
+
if (!Number.isInteger(digits) || digits < 1) {
|
|
87
|
+
throw new Error(`randomCode: digits must be a positive integer, got ${digits}`);
|
|
88
|
+
}
|
|
89
|
+
const modulus = 10n ** BigInt(digits);
|
|
90
|
+
// Enough whole bytes to cover the modulus, with one spare byte so the
|
|
91
|
+
// accepted region is a large fraction of the draw space (few rejections).
|
|
92
|
+
const byteLen = Math.ceil((digits * Math.log2(10)) / 8) + 1;
|
|
93
|
+
const space = 1n << BigInt(byteLen * 8);
|
|
94
|
+
// Largest multiple of `modulus` that fits in `space`; draws at or above it
|
|
95
|
+
// would bias the low digits, so reject and redraw.
|
|
96
|
+
const limit = space - (space % modulus);
|
|
97
|
+
for (;;) {
|
|
98
|
+
let value = 0n;
|
|
99
|
+
for (const byte of randomBytes(byteLen)) {
|
|
100
|
+
value = (value << 8n) | BigInt(byte);
|
|
101
|
+
}
|
|
102
|
+
if (value < limit) {
|
|
103
|
+
return (value % modulus).toString().padStart(digits, '0');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import {
|
|
2
|
+
z,
|
|
3
|
+
defineTool,
|
|
4
|
+
definePlugin,
|
|
5
|
+
MoxxyError,
|
|
6
|
+
type Plugin,
|
|
7
|
+
type CommandDef,
|
|
8
|
+
type EmittedEvent,
|
|
9
|
+
type TurnId,
|
|
10
|
+
} from '@moxxy/sdk';
|
|
11
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
12
|
+
import { createCombinedKeySource, type MasterKeySource } from './keysource.js';
|
|
13
|
+
import { resolveString } from './placeholder.js';
|
|
14
|
+
import { VaultStore } from './store.js';
|
|
15
|
+
|
|
16
|
+
export { VaultStore, VaultPassphraseError } from './store.js';
|
|
17
|
+
export type { VaultEntry, VaultEntryInfo, VaultStoreOptions } from './store.js';
|
|
18
|
+
export { createCombinedKeySource, createStaticKeySource, type MasterKeySource } from './keysource.js';
|
|
19
|
+
export { resolveString, resolveValue, containsPlaceholder } from './placeholder.js';
|
|
20
|
+
export { deriveKey, deriveKeyAsync, encrypt, decrypt, generateSalt, randomCode } from './crypto.js';
|
|
21
|
+
|
|
22
|
+
export interface BuildVaultPluginOptions {
|
|
23
|
+
readonly filePath?: string;
|
|
24
|
+
readonly keySource?: MasterKeySource;
|
|
25
|
+
readonly passphrasePrompt?: () => Promise<string>;
|
|
26
|
+
readonly envVar?: string;
|
|
27
|
+
readonly disableKeytar?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultVaultPath(): string {
|
|
31
|
+
return moxxyPath('vault.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildVaultPlugin(opts: BuildVaultPluginOptions = {}): { plugin: Plugin; vault: VaultStore } {
|
|
35
|
+
const filePath = opts.filePath ?? defaultVaultPath();
|
|
36
|
+
const keySource =
|
|
37
|
+
opts.keySource ??
|
|
38
|
+
createCombinedKeySource({
|
|
39
|
+
passphrasePrompt: opts.passphrasePrompt ?? defaultPrompt,
|
|
40
|
+
envVar: opts.envVar,
|
|
41
|
+
disableKeytar: opts.disableKeytar,
|
|
42
|
+
});
|
|
43
|
+
const vault = new VaultStore({ filePath, keySource });
|
|
44
|
+
|
|
45
|
+
// `/vault` slash command. Lets the USER store a secret out-of-band: the
|
|
46
|
+
// value travels in the slash-command args, which channels intercept and
|
|
47
|
+
// never send to the model. After storing, we inject a note into the event
|
|
48
|
+
// log telling the model only the REFERENCE (`${vault:NAME}`) — never the
|
|
49
|
+
// plaintext — so the model can wire it up (config/tools) without seeing it.
|
|
50
|
+
const vaultCmd: CommandDef = {
|
|
51
|
+
name: 'vault',
|
|
52
|
+
description: 'Store a secret or list stored names',
|
|
53
|
+
argumentHint: 'set <name> <value>',
|
|
54
|
+
pendingNotice: 'updating vault',
|
|
55
|
+
handler: async (ctx) => {
|
|
56
|
+
const trimmed = ctx.args.trim();
|
|
57
|
+
const [sub, ...rest] = trimmed.split(/\s+/);
|
|
58
|
+
|
|
59
|
+
if (!sub || sub === 'help') {
|
|
60
|
+
return { kind: 'text', text: vaultCommandUsage() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (sub === 'list') {
|
|
64
|
+
const entries = await vault.list();
|
|
65
|
+
if (entries.length === 0) {
|
|
66
|
+
return { kind: 'text', text: 'Vault is empty. Store a secret with `/vault set <name> <value>`.' };
|
|
67
|
+
}
|
|
68
|
+
const lines = entries.map(
|
|
69
|
+
(e) => ` ${e.name}${e.tags && e.tags.length ? ` [${e.tags.join(', ')}]` : ''} → \${vault:${e.name}}`,
|
|
70
|
+
);
|
|
71
|
+
return { kind: 'text', text: `Stored secrets (values hidden):\n${lines.join('\n')}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (sub === 'set') {
|
|
75
|
+
const name = rest[0];
|
|
76
|
+
const value = rest.slice(1).join(' ');
|
|
77
|
+
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
78
|
+
return {
|
|
79
|
+
kind: 'error',
|
|
80
|
+
message: 'usage: /vault set <name> <value> (name may contain letters, digits, _ . -)',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!value) {
|
|
84
|
+
return { kind: 'error', message: `usage: /vault set ${name} <value> — a value is required` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await vault.set(name, value);
|
|
88
|
+
|
|
89
|
+
// Inform the model with a reference only — never the plaintext. Keep it
|
|
90
|
+
// terse: the behavioral guidance ("never ask for plaintext") lives in
|
|
91
|
+
// the vault-setup skill. The note projects as a message on the next turn
|
|
92
|
+
// and renders as a dim system note in the TUI (see EventLine).
|
|
93
|
+
const note =
|
|
94
|
+
`[vault] Secret "${name}" stored. Reference it as \${vault:${name}} — its value is hidden from you.`;
|
|
95
|
+
const s = ctx.session as Partial<VaultCommandSession>;
|
|
96
|
+
if (typeof s.log?.append === 'function' && typeof s.startTurn === 'function') {
|
|
97
|
+
try {
|
|
98
|
+
await s.log.append({
|
|
99
|
+
type: 'user_prompt',
|
|
100
|
+
sessionId: ctx.sessionId,
|
|
101
|
+
turnId: s.startTurn().turnId,
|
|
102
|
+
source: 'system',
|
|
103
|
+
text: note,
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// The session exposes the log API but the append itself failed
|
|
107
|
+
// (e.g. disk full / log-rotation race). The secret IS stored, so
|
|
108
|
+
// still confirm to the user below, but surface a warning so a
|
|
109
|
+
// broken log path is diagnosable rather than a silent partial
|
|
110
|
+
// failure where the model never learns the reference exists.
|
|
111
|
+
console.warn(
|
|
112
|
+
`[vault] stored "${name}" but failed to record its reference note: ` +
|
|
113
|
+
(err instanceof Error ? err.message : String(err)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// If the host session doesn't expose log/startTurn (unexpected),
|
|
118
|
+
// we skip the note entirely and just confirm storage below.
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: 'text',
|
|
122
|
+
text:
|
|
123
|
+
`✓ Stored "${name}" securely in the vault. ` +
|
|
124
|
+
`The assistant can reference it as \${vault:${name}} without ever seeing the value.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { kind: 'error', message: `unknown subcommand "${sub}".\n${vaultCommandUsage()}` };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const plugin = definePlugin({
|
|
133
|
+
name: '@moxxy/plugin-vault',
|
|
134
|
+
version: '0.0.0',
|
|
135
|
+
// Publish the secret store on the inter-plugin service registry so sibling
|
|
136
|
+
// plugins (oauth, telegram, mcp, …) can resolve it in their own onInit
|
|
137
|
+
// instead of being hand-built with `{ vault }` — the seam that lets them be
|
|
138
|
+
// discovery-loaded. Consumers declare a requirement on @moxxy/plugin-vault
|
|
139
|
+
// so this onInit runs first.
|
|
140
|
+
hooks: {
|
|
141
|
+
onInit: (ctx) => {
|
|
142
|
+
ctx.services.register('vault', vault);
|
|
143
|
+
// Also publish a `${vault:NAME}`-placeholder resolver so consumers (mcp's
|
|
144
|
+
// server env/headers) can resolve secrets without depending on the vault
|
|
145
|
+
// package's `resolveString` helper directly.
|
|
146
|
+
ctx.services.register(
|
|
147
|
+
'resolveSecrets',
|
|
148
|
+
(value: string): Promise<string> => resolveString(value, vault),
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
commands: [vaultCmd],
|
|
153
|
+
tools: [
|
|
154
|
+
defineTool({
|
|
155
|
+
name: 'vault_set',
|
|
156
|
+
description:
|
|
157
|
+
'Store a secret in the encrypted vault. Overwrites if name exists. ' +
|
|
158
|
+
'IMPORTANT: do NOT use this for a secret the USER supplies — that would route the ' +
|
|
159
|
+
'plaintext through the conversation. Instead tell the user to run `/vault set <name> <value>` ' +
|
|
160
|
+
'themselves; you only receive a ${vault:<name>} reference. Use this tool only for a value you ' +
|
|
161
|
+
'legitimately already hold and may see.',
|
|
162
|
+
inputSchema: z.object({
|
|
163
|
+
name: z.string().min(1).regex(/^[A-Za-z0-9_.-]+$/),
|
|
164
|
+
value: z.string().min(1),
|
|
165
|
+
tags: z.array(z.string()).optional(),
|
|
166
|
+
}),
|
|
167
|
+
permission: { action: 'prompt' },
|
|
168
|
+
handler: async ({ name, value, tags }) => {
|
|
169
|
+
await vault.set(name, value, tags);
|
|
170
|
+
return `stored ${name} (${value.length} chars) in vault`;
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
defineTool({
|
|
174
|
+
name: 'vault_get',
|
|
175
|
+
description: 'Fetch a secret from the encrypted vault by name. Returns the plaintext value.',
|
|
176
|
+
inputSchema: z.object({ name: z.string().min(1) }),
|
|
177
|
+
permission: { action: 'prompt' },
|
|
178
|
+
handler: async ({ name }) => {
|
|
179
|
+
const value = await vault.get(name);
|
|
180
|
+
if (value === null) {
|
|
181
|
+
throw new MoxxyError({
|
|
182
|
+
code: 'TOOL_ERROR',
|
|
183
|
+
message: `vault: '${name}' not found`,
|
|
184
|
+
hint: 'Use `vault_list` to see stored entry names.',
|
|
185
|
+
context: { name },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
defineTool({
|
|
192
|
+
name: 'vault_list',
|
|
193
|
+
description: 'List entries in the vault. Returns names + metadata only, never plaintext.',
|
|
194
|
+
inputSchema: z.object({}),
|
|
195
|
+
permission: { action: 'prompt' },
|
|
196
|
+
handler: async () => {
|
|
197
|
+
const entries = await vault.list();
|
|
198
|
+
return entries.map((e) => ({ name: e.name, createdAt: e.createdAt, tags: e.tags ?? [] }));
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
defineTool({
|
|
202
|
+
name: 'vault_delete',
|
|
203
|
+
description: 'Delete a vault entry by name.',
|
|
204
|
+
inputSchema: z.object({ name: z.string().min(1) }),
|
|
205
|
+
permission: { action: 'prompt' },
|
|
206
|
+
handler: async ({ name }) => {
|
|
207
|
+
const removed = await vault.delete(name);
|
|
208
|
+
return removed ? `deleted ${name}` : `not found: ${name}`;
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
defineTool({
|
|
212
|
+
name: 'vault_status',
|
|
213
|
+
description: 'Report which key source unlocked the vault (keychain, env, passphrase).',
|
|
214
|
+
inputSchema: z.object({}),
|
|
215
|
+
handler: async () => {
|
|
216
|
+
await vault.open();
|
|
217
|
+
const entries = await vault.list();
|
|
218
|
+
return { source: vault.sourceName, entries: entries.length };
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return { plugin, vault };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Minimal slice of the real Session the /vault command needs to inject its
|
|
229
|
+
* reference note. Loosely typed so the package stays free of a core dep —
|
|
230
|
+
* the plugin host passes a real Session that satisfies this.
|
|
231
|
+
*/
|
|
232
|
+
interface VaultCommandSession {
|
|
233
|
+
startTurn(): { turnId: TurnId };
|
|
234
|
+
log: { append(event: EmittedEvent): Promise<unknown> };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function vaultCommandUsage(): string {
|
|
238
|
+
return (
|
|
239
|
+
'usage:\n' +
|
|
240
|
+
' /vault set <name> <value> store a secret (the value is hidden from the assistant)\n' +
|
|
241
|
+
' /vault list list stored secret names'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function defaultPrompt(): Promise<string> {
|
|
246
|
+
// Headless default: refuse if no TTY. Interactive shells override this via opts.
|
|
247
|
+
if (!process.stdin.isTTY) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
'vault: passphrase required but no interactive terminal. Set MOXXY_VAULT_PASSPHRASE or pass a custom passphrasePrompt.',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const readline = await import('node:readline/promises');
|
|
253
|
+
// Write to stdout (not stderr) and include a leading newline + bold-ish
|
|
254
|
+
// banner so users can't miss the prompt — the bare `vault passphrase: `
|
|
255
|
+
// sent to stderr was easy to overlook (looked like a hang).
|
|
256
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
257
|
+
try {
|
|
258
|
+
process.stdout.write(
|
|
259
|
+
'\n[1m[33mmoxxy vault[39m[22m needs a passphrase.\n' +
|
|
260
|
+
'Pick one now (it\'s stored in your OS keychain if available).\n' +
|
|
261
|
+
'Set [2mMOXXY_VAULT_PASSPHRASE[22m to skip this prompt.\n\n',
|
|
262
|
+
);
|
|
263
|
+
const answer = (await rl.question('passphrase: ')).trim();
|
|
264
|
+
process.stdout.write('\n');
|
|
265
|
+
return answer;
|
|
266
|
+
} finally {
|
|
267
|
+
rl.close();
|
|
268
|
+
}
|
|
269
|
+
}
|