@tuskydp/cli 0.1.2 → 0.2.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/tusky-dev.sh +5 -0
- package/dist/src/commands/account.d.ts.map +1 -1
- package/dist/src/commands/account.js +32 -2
- package/dist/src/commands/account.js.map +1 -1
- package/dist/src/commands/download.d.ts.map +1 -1
- package/dist/src/commands/download.js +27 -7
- package/dist/src/commands/download.js.map +1 -1
- package/dist/src/commands/upload.d.ts.map +1 -1
- package/dist/src/commands/upload.js +23 -2
- package/dist/src/commands/upload.js.map +1 -1
- package/dist/src/commands/vault.d.ts.map +1 -1
- package/dist/src/commands/vault.js +145 -7
- package/dist/src/commands/vault.js.map +1 -1
- package/dist/src/config.d.ts +5 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +7 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/mcp/context.d.ts +11 -2
- package/dist/src/mcp/context.d.ts.map +1 -1
- package/dist/src/mcp/context.js +3 -2
- package/dist/src/mcp/context.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +13 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/files.d.ts +2 -1
- package/dist/src/mcp/tools/files.d.ts.map +1 -1
- package/dist/src/mcp/tools/files.js +49 -12
- package/dist/src/mcp/tools/files.js.map +1 -1
- package/dist/src/mcp/tools/sharedVaults.d.ts +7 -0
- package/dist/src/mcp/tools/sharedVaults.d.ts.map +1 -0
- package/dist/src/mcp/tools/sharedVaults.js +90 -0
- package/dist/src/mcp/tools/sharedVaults.js.map +1 -0
- package/dist/src/mcp/tools/vaults.js +1 -1
- package/dist/src/mcp/tools/vaults.js.map +1 -1
- package/dist/src/seal.d.ts +58 -0
- package/dist/src/seal.d.ts.map +1 -0
- package/dist/src/seal.js +207 -0
- package/dist/src/seal.js.map +1 -0
- package/package.json +16 -13
- package/src/__tests__/seal.test.ts +357 -0
- package/src/commands/account.ts +35 -2
- package/src/commands/download.ts +34 -13
- package/src/commands/upload.ts +25 -2
- package/src/commands/vault.ts +158 -7
- package/src/config.ts +8 -0
- package/src/mcp/context.ts +13 -2
- package/src/mcp/server.ts +13 -0
- package/src/mcp/tools/files.ts +61 -19
- package/src/mcp/tools/sharedVaults.ts +122 -0
- package/src/mcp/tools/vaults.ts +2 -2
- package/src/seal.ts +266 -0
package/src/commands/download.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { formatBytes } from '../lib/output.js';
|
|
|
8
8
|
import { createSpinner } from '../lib/progress.js';
|
|
9
9
|
import { decryptBuffer } from '../crypto.js';
|
|
10
10
|
import { loadMasterKey } from '../lib/keyring.js';
|
|
11
|
+
import { sealDecrypt, getSuiKeypair } from '../seal.js';
|
|
11
12
|
|
|
12
13
|
export async function downloadCommand(fileId: string, options: {
|
|
13
14
|
output?: string;
|
|
@@ -53,21 +54,41 @@ export async function downloadCommand(fileId: string, options: {
|
|
|
53
54
|
const arrayBuf = await response.arrayBuffer();
|
|
54
55
|
let fileBuffer: Buffer = Buffer.from(new Uint8Array(arrayBuf));
|
|
55
56
|
|
|
56
|
-
// Decrypt if needed
|
|
57
|
+
// Decrypt if needed — dispatch on encryption type
|
|
57
58
|
if (encryption?.encrypted) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
if ('type' in encryption && encryption.type === 'seal') {
|
|
60
|
+
// SEAL-encrypted (shared vault)
|
|
61
|
+
spinner.text = 'SEAL decrypting...';
|
|
62
|
+
const keypair = getSuiKeypair();
|
|
63
|
+
if (!keypair) {
|
|
64
|
+
spinner.fail(
|
|
65
|
+
'TUSKYDP_SUI_PRIVATE_KEY env var required to decrypt shared vault files (SEAL encryption).',
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Need vault details for SEAL config
|
|
70
|
+
const fileInfo = await sdk.files.get(fileId);
|
|
71
|
+
const vault = await sdk.vaults.get(fileInfo.vaultId);
|
|
72
|
+
const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
|
|
73
|
+
fileBuffer = Buffer.from(decrypted);
|
|
74
|
+
} else {
|
|
75
|
+
// Passphrase-encrypted (private vault)
|
|
76
|
+
spinner.text = 'Decrypting...';
|
|
77
|
+
const masterKey = loadMasterKey();
|
|
78
|
+
if (!masterKey) {
|
|
79
|
+
spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Narrow to passphrase type
|
|
83
|
+
const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
|
|
84
|
+
fileBuffer = decryptBuffer(
|
|
85
|
+
fileBuffer,
|
|
86
|
+
enc.wrappedKey,
|
|
87
|
+
enc.iv,
|
|
88
|
+
masterKey,
|
|
89
|
+
enc.plaintextChecksumSha256 ?? undefined,
|
|
90
|
+
);
|
|
63
91
|
}
|
|
64
|
-
fileBuffer = decryptBuffer(
|
|
65
|
-
fileBuffer,
|
|
66
|
-
encryption.wrappedKey!,
|
|
67
|
-
encryption.iv!,
|
|
68
|
-
masterKey,
|
|
69
|
-
encryption.plaintextChecksumSha256 ?? undefined,
|
|
70
|
-
);
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
// Write to disk — use getStatus to get filename
|
package/src/commands/upload.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { readFileSync, statSync, readdirSync } from 'fs';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
3
4
|
import { basename, join, resolve } from 'path';
|
|
4
5
|
import { lookup } from 'mime-types';
|
|
5
6
|
import chalk from 'chalk';
|
|
@@ -10,6 +11,7 @@ import { formatBytes } from '../lib/output.js';
|
|
|
10
11
|
import { createSpinner } from '../lib/progress.js';
|
|
11
12
|
import { encryptBuffer } from '../crypto.js';
|
|
12
13
|
import { loadMasterKey } from '../lib/keyring.js';
|
|
14
|
+
import { isSealConfigured, sealEncrypt, getSuiKeypair } from '../seal.js';
|
|
13
15
|
|
|
14
16
|
async function expandPaths(paths: string[], recursive?: boolean): Promise<string[]> {
|
|
15
17
|
const result: string[] = [];
|
|
@@ -50,6 +52,7 @@ export async function uploadCommand(paths: string[], options: {
|
|
|
50
52
|
const vaultId = await resolveVault(sdk, options.vault);
|
|
51
53
|
const vault = await sdk.vaults.get(vaultId);
|
|
52
54
|
const isPrivate = vault.visibility === 'private';
|
|
55
|
+
const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
|
|
53
56
|
|
|
54
57
|
let masterKey: Buffer | null = null;
|
|
55
58
|
if (isPrivate) {
|
|
@@ -60,6 +63,14 @@ export async function uploadCommand(paths: string[], options: {
|
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
if (isShared) {
|
|
67
|
+
const keypair = getSuiKeypair();
|
|
68
|
+
if (!keypair) {
|
|
69
|
+
console.error(chalk.red('TUSKYDP_SUI_PRIVATE_KEY env var required for shared vault uploads (SEAL encryption).'));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
const filePaths = await expandPaths(paths, options.recursive);
|
|
64
75
|
if (filePaths.length === 0) {
|
|
65
76
|
console.log(chalk.yellow('No files to upload.'));
|
|
@@ -84,6 +95,8 @@ export async function uploadCommand(paths: string[], options: {
|
|
|
84
95
|
encryptionIv?: string;
|
|
85
96
|
plaintextSizeBytes?: number;
|
|
86
97
|
plaintextChecksumSha256?: string;
|
|
98
|
+
sealIdentity?: string;
|
|
99
|
+
sealEncryptedObject?: string;
|
|
87
100
|
} = {};
|
|
88
101
|
|
|
89
102
|
if (isPrivate && masterKey) {
|
|
@@ -96,6 +109,16 @@ export async function uploadCommand(paths: string[], options: {
|
|
|
96
109
|
plaintextSizeBytes: stat.size,
|
|
97
110
|
plaintextChecksumSha256: plaintextChecksum,
|
|
98
111
|
};
|
|
112
|
+
} else if (isShared) {
|
|
113
|
+
spinner.text = `SEAL encrypting ${basename(filePath)}...`;
|
|
114
|
+
const fileNonce = randomUUID();
|
|
115
|
+
const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
|
|
116
|
+
uploadBody = Buffer.from(sealResult.encryptedData);
|
|
117
|
+
encryptionMeta = {
|
|
118
|
+
sealIdentity: sealResult.sealIdentity,
|
|
119
|
+
sealEncryptedObject: sealResult.sealEncryptedObject,
|
|
120
|
+
plaintextSizeBytes: stat.size,
|
|
121
|
+
};
|
|
99
122
|
} else {
|
|
100
123
|
uploadBody = fileBuffer;
|
|
101
124
|
}
|
|
@@ -137,7 +160,7 @@ export async function uploadCommand(paths: string[], options: {
|
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
if (filePaths.length > 1) {
|
|
140
|
-
const
|
|
141
|
-
console.log(`\nUploaded ${successCount} file(s) (${formatBytes(totalSize)}) to vault "${vault.name}" [${
|
|
163
|
+
const label = isPrivate ? 'private' : isShared ? 'shared' : 'public';
|
|
164
|
+
console.log(`\nUploaded ${successCount} file(s) (${formatBytes(totalSize)}) to vault "${vault.name}" [${label}]`);
|
|
142
165
|
}
|
|
143
166
|
}
|
package/src/commands/vault.ts
CHANGED
|
@@ -3,19 +3,35 @@ import chalk from 'chalk';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import { cliConfig } from '../config.js';
|
|
5
5
|
import { getSDKClientFromParent } from '../sdk.js';
|
|
6
|
-
import { createTable, formatBytes, shortId } from '../lib/output.js';
|
|
6
|
+
import { createTable, formatBytes, formatDate, shortId } from '../lib/output.js';
|
|
7
7
|
import { resolveVault } from '../lib/resolve.js';
|
|
8
8
|
|
|
9
|
+
/** Map vault visibility to a display label. */
|
|
10
|
+
function visibilityLabel(v: string): string {
|
|
11
|
+
if (v === 'private') return 'private';
|
|
12
|
+
if (v === 'shared') return 'shared';
|
|
13
|
+
return 'public';
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
export function registerVaultCommands(program: Command) {
|
|
10
17
|
const vault = program.command('vault').description('Manage vaults');
|
|
11
18
|
|
|
19
|
+
// ── create ──────────────────────────────────────────────────────────
|
|
12
20
|
vault.command('create <name>')
|
|
13
21
|
.description('Create a new vault (private/encrypted by default)')
|
|
14
22
|
.option('--public', 'Create as public vault (unencrypted, shareable via URL)')
|
|
23
|
+
.option('--shared', 'Create as shared vault (SEAL-encrypted, requires linked Sui address)')
|
|
15
24
|
.option('--description <text>', 'Vault description')
|
|
16
25
|
.action(async (name: string, options) => {
|
|
17
26
|
const sdk = getSDKClientFromParent(vault);
|
|
18
|
-
|
|
27
|
+
let visibility: 'public' | 'private' | 'shared' = 'private';
|
|
28
|
+
if (options.public) visibility = 'public';
|
|
29
|
+
if (options.shared) visibility = 'shared';
|
|
30
|
+
|
|
31
|
+
if (options.public && options.shared) {
|
|
32
|
+
console.error(chalk.red('Cannot use both --public and --shared.'));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
if (visibility === 'private') {
|
|
21
37
|
try {
|
|
@@ -30,11 +46,28 @@ export function registerVaultCommands(program: Command) {
|
|
|
30
46
|
}
|
|
31
47
|
}
|
|
32
48
|
|
|
49
|
+
if (visibility === 'shared') {
|
|
50
|
+
try {
|
|
51
|
+
const acct = await sdk.account.get();
|
|
52
|
+
if (!(acct as any).suiAddress) {
|
|
53
|
+
console.error(chalk.red('A Sui address must be linked to create shared vaults.'));
|
|
54
|
+
console.error(chalk.dim(' Run: tusky account link-sui <address>'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Proceed and let the API return the error
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
const created = await sdk.vaults.create({ name, description: options.description, visibility });
|
|
34
|
-
|
|
35
|
-
|
|
63
|
+
console.log(chalk.green(`Created ${visibility} vault [${visibilityLabel(visibility)}] "${created.name}" (${created.id})`));
|
|
64
|
+
|
|
65
|
+
if (visibility === 'shared' && created.sealAllowlistObjectId) {
|
|
66
|
+
console.log(chalk.dim(` SEAL allowlist: ${created.sealAllowlistObjectId}`));
|
|
67
|
+
}
|
|
36
68
|
});
|
|
37
69
|
|
|
70
|
+
// ── list ────────────────────────────────────────────────────────────
|
|
38
71
|
vault.command('list')
|
|
39
72
|
.description('List all vaults')
|
|
40
73
|
.action(async () => {
|
|
@@ -54,11 +87,10 @@ export function registerVaultCommands(program: Command) {
|
|
|
54
87
|
|
|
55
88
|
const table = createTable(['Name', 'Slug', 'Visibility', 'Files', 'Size', 'ID']);
|
|
56
89
|
for (const v of vaults) {
|
|
57
|
-
const visIcon = v.visibility === 'private' ? 'private' : 'public';
|
|
58
90
|
table.push([
|
|
59
91
|
v.isDefault ? `${v.name} ${chalk.dim('(default)')}` : v.name,
|
|
60
92
|
v.slug,
|
|
61
|
-
|
|
93
|
+
visibilityLabel(v.visibility),
|
|
62
94
|
String(v.fileCount),
|
|
63
95
|
formatBytes(v.totalSizeBytes),
|
|
64
96
|
chalk.dim(shortId(v.id)),
|
|
@@ -67,6 +99,40 @@ export function registerVaultCommands(program: Command) {
|
|
|
67
99
|
console.log(table.toString());
|
|
68
100
|
});
|
|
69
101
|
|
|
102
|
+
// ── shared ──────────────────────────────────────────────────────────
|
|
103
|
+
vault.command('shared')
|
|
104
|
+
.description('List shared vaults you are a member of (not owner)')
|
|
105
|
+
.action(async () => {
|
|
106
|
+
const sdk = getSDKClientFromParent(vault);
|
|
107
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
108
|
+
const { vaults } = await sdk.sharedVaults.list();
|
|
109
|
+
|
|
110
|
+
if (format === 'json') {
|
|
111
|
+
console.log(JSON.stringify(vaults, null, 2));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (vaults.length === 0) {
|
|
116
|
+
console.log(chalk.dim('No shared vault memberships found.'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const table = createTable(['Name', 'Slug', 'Role', 'Files', 'Size', 'Granted', 'ID']);
|
|
121
|
+
for (const entry of vaults) {
|
|
122
|
+
table.push([
|
|
123
|
+
entry.vault.name,
|
|
124
|
+
entry.vault.slug,
|
|
125
|
+
entry.role,
|
|
126
|
+
String(entry.vault.fileCount),
|
|
127
|
+
formatBytes(entry.vault.totalSizeBytes),
|
|
128
|
+
formatDate(entry.grantedAt),
|
|
129
|
+
chalk.dim(shortId(entry.vault.id)),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
console.log(table.toString());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── info ────────────────────────────────────────────────────────────
|
|
70
136
|
vault.command('info <vault>')
|
|
71
137
|
.description('Show vault details')
|
|
72
138
|
.action(async (vaultRef: string) => {
|
|
@@ -82,15 +148,47 @@ export function registerVaultCommands(program: Command) {
|
|
|
82
148
|
|
|
83
149
|
console.log(`Name: ${v.name}`);
|
|
84
150
|
console.log(`Slug: ${v.slug}`);
|
|
85
|
-
console.log(`Visibility: ${v.visibility
|
|
151
|
+
console.log(`Visibility: ${visibilityLabel(v.visibility)}`);
|
|
86
152
|
console.log(`Description: ${v.description || chalk.dim('(none)')}`);
|
|
87
153
|
console.log(`Files: ${v.fileCount}`);
|
|
88
154
|
console.log(`Total Size: ${formatBytes(v.totalSizeBytes)}`);
|
|
89
155
|
console.log(`Default: ${v.isDefault ? 'Yes' : 'No'}`);
|
|
90
156
|
console.log(`Created: ${new Date(v.createdAt).toLocaleString()}`);
|
|
91
157
|
console.log(`ID: ${v.id}`);
|
|
158
|
+
|
|
159
|
+
if (v.visibility === 'shared') {
|
|
160
|
+
console.log('');
|
|
161
|
+
console.log(chalk.bold('SEAL Configuration:'));
|
|
162
|
+
console.log(` Allowlist: ${v.sealAllowlistObjectId || chalk.dim('(none)')}`);
|
|
163
|
+
console.log(` Package: ${v.sealPackageId || chalk.dim('(none)')}`);
|
|
164
|
+
console.log(` Threshold: ${v.sealThreshold ?? chalk.dim('(none)')}`);
|
|
165
|
+
console.log(` Key Servers: ${v.sealKeyServerIds?.length ?? 0}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── update ──────────────────────────────────────────────────────────
|
|
170
|
+
vault.command('update <vault>')
|
|
171
|
+
.description('Update vault name and/or description')
|
|
172
|
+
.option('--name <name>', 'New vault name')
|
|
173
|
+
.option('--description <text>', 'New vault description')
|
|
174
|
+
.action(async (vaultRef: string, options) => {
|
|
175
|
+
const sdk = getSDKClientFromParent(vault);
|
|
176
|
+
const vaultId = await resolveVault(sdk, vaultRef);
|
|
177
|
+
|
|
178
|
+
if (!options.name && !options.description) {
|
|
179
|
+
console.error(chalk.red('Provide --name and/or --description to update.'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const params: { name?: string; description?: string } = {};
|
|
184
|
+
if (options.name) params.name = options.name;
|
|
185
|
+
if (options.description) params.description = options.description;
|
|
186
|
+
|
|
187
|
+
await sdk.vaults.update(vaultId, params);
|
|
188
|
+
console.log(chalk.green('Vault updated.'));
|
|
92
189
|
});
|
|
93
190
|
|
|
191
|
+
// ── rename (alias for update --name) ────────────────────────────────
|
|
94
192
|
vault.command('rename <vault> <new-name>')
|
|
95
193
|
.description('Rename a vault')
|
|
96
194
|
.action(async (vaultRef: string, newName: string) => {
|
|
@@ -100,6 +198,7 @@ export function registerVaultCommands(program: Command) {
|
|
|
100
198
|
console.log(chalk.green(`Vault renamed to "${newName}"`));
|
|
101
199
|
});
|
|
102
200
|
|
|
201
|
+
// ── delete ──────────────────────────────────────────────────────────
|
|
103
202
|
vault.command('delete <vault>')
|
|
104
203
|
.description('Delete a vault')
|
|
105
204
|
.option('--force', 'Delete vault and all its files')
|
|
@@ -121,6 +220,7 @@ export function registerVaultCommands(program: Command) {
|
|
|
121
220
|
console.log(chalk.green('Vault deleted.'));
|
|
122
221
|
});
|
|
123
222
|
|
|
223
|
+
// ── set-default ─────────────────────────────────────────────────────
|
|
124
224
|
vault.command('set-default <vault>')
|
|
125
225
|
.description('Set the default vault for uploads')
|
|
126
226
|
.action(async (vaultRef: string) => {
|
|
@@ -129,4 +229,55 @@ export function registerVaultCommands(program: Command) {
|
|
|
129
229
|
cliConfig.set('defaultVault', vaultId);
|
|
130
230
|
console.log(chalk.green(`Default vault set to ${vaultRef}`));
|
|
131
231
|
});
|
|
232
|
+
|
|
233
|
+
// ── members ─────────────────────────────────────────────────────────
|
|
234
|
+
const members = vault.command('members').description('Manage shared vault members');
|
|
235
|
+
|
|
236
|
+
members.command('list <vault>')
|
|
237
|
+
.description('List members of a shared vault')
|
|
238
|
+
.action(async (vaultRef: string) => {
|
|
239
|
+
const sdk = getSDKClientFromParent(vault);
|
|
240
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
241
|
+
const vaultId = await resolveVault(sdk, vaultRef);
|
|
242
|
+
const { members: memberList } = await sdk.sharedVaults.listMembers(vaultId);
|
|
243
|
+
|
|
244
|
+
if (format === 'json') {
|
|
245
|
+
console.log(JSON.stringify(memberList, null, 2));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (memberList.length === 0) {
|
|
250
|
+
console.log(chalk.dim('No members found.'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const table = createTable(['Sui Address', 'Role', 'Granted', 'Member ID']);
|
|
255
|
+
for (const m of memberList) {
|
|
256
|
+
table.push([
|
|
257
|
+
m.suiAddress,
|
|
258
|
+
m.role,
|
|
259
|
+
formatDate(m.grantedAt),
|
|
260
|
+
chalk.dim(shortId(m.id)),
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
console.log(table.toString());
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
members.command('add <vault> <sui-address>')
|
|
267
|
+
.description('Grant access to a shared vault by Sui address')
|
|
268
|
+
.action(async (vaultRef: string, suiAddress: string) => {
|
|
269
|
+
const sdk = getSDKClientFromParent(vault);
|
|
270
|
+
const vaultId = await resolveVault(sdk, vaultRef);
|
|
271
|
+
const { member } = await sdk.sharedVaults.addMember(vaultId, { suiAddress });
|
|
272
|
+
console.log(chalk.green(`Access granted to ${suiAddress} (member ID: ${member.id})`));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
members.command('remove <vault> <member-id>')
|
|
276
|
+
.description('Revoke access from a shared vault member')
|
|
277
|
+
.action(async (vaultRef: string, memberId: string) => {
|
|
278
|
+
const sdk = getSDKClientFromParent(vault);
|
|
279
|
+
const vaultId = await resolveVault(sdk, vaultRef);
|
|
280
|
+
await sdk.sharedVaults.removeMember(vaultId, memberId);
|
|
281
|
+
console.log(chalk.green(`Access revoked for member ${memberId}`));
|
|
282
|
+
});
|
|
132
283
|
}
|
package/src/config.ts
CHANGED
|
@@ -36,3 +36,11 @@ export function getOutputFormat(override?: string): 'table' | 'json' | 'plain' {
|
|
|
36
36
|
if (override && VALID_FORMATS.has(override)) return override as 'table' | 'json' | 'plain';
|
|
37
37
|
return cliConfig.get('outputFormat');
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the Sui private key for SEAL operations on shared vaults.
|
|
42
|
+
* Only read from env var (never persisted to config for security).
|
|
43
|
+
*/
|
|
44
|
+
export function getSuiPrivateKey(): string | null {
|
|
45
|
+
return process.env.TUSKYDP_SUI_PRIVATE_KEY || null;
|
|
46
|
+
}
|
package/src/mcp/context.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared context for MCP tool handlers.
|
|
3
3
|
*
|
|
4
|
-
* Holds the authenticated SDK client
|
|
5
|
-
*
|
|
4
|
+
* Holds the authenticated SDK client, the unlocked master key for
|
|
5
|
+
* private vault encryption, and the Sui keypair for shared vault
|
|
6
|
+
* SEAL encryption/decryption.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { TuskyClient } from '@tuskydp/sdk';
|
|
10
|
+
import type { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
|
|
9
11
|
|
|
10
12
|
export interface McpContext {
|
|
11
13
|
/** Authenticated Tusky SDK client. */
|
|
@@ -19,4 +21,13 @@ export interface McpContext {
|
|
|
19
21
|
|
|
20
22
|
/** True when the master key is available for encrypt/decrypt operations. */
|
|
21
23
|
isEncryptionReady(): boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the Sui Ed25519 keypair for SEAL operations, or null if
|
|
27
|
+
* TUSKYDP_SUI_PRIVATE_KEY was not provided.
|
|
28
|
+
*/
|
|
29
|
+
getSuiKeypair(): Ed25519Keypair | null;
|
|
30
|
+
|
|
31
|
+
/** True when the Sui keypair is available for shared vault operations. */
|
|
32
|
+
isSealReady(): boolean;
|
|
22
33
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
unwrapMasterKey,
|
|
16
16
|
} from '../crypto.js';
|
|
17
17
|
import { loadMasterKey } from '../lib/keyring.js';
|
|
18
|
+
import { getSuiKeypair } from '../seal.js';
|
|
18
19
|
import type { McpContext } from './context.js';
|
|
19
20
|
|
|
20
21
|
// Tool registrars
|
|
@@ -23,6 +24,7 @@ import { registerVaultTools } from './tools/vaults.js';
|
|
|
23
24
|
import { registerFolderTools } from './tools/folders.js';
|
|
24
25
|
import { registerFileTools } from './tools/files.js';
|
|
25
26
|
import { registerTrashTools } from './tools/trash.js';
|
|
27
|
+
import { registerSharedVaultTools } from './tools/sharedVaults.js';
|
|
26
28
|
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
28
30
|
// Encryption bootstrap
|
|
@@ -113,11 +115,21 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
|
|
|
113
115
|
console.error('[tusky-mcp] Set TUSKYDP_PASSWORD env var or run `tusky encryption unlock` first.');
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
// Load Sui keypair for SEAL shared vault operations (best effort)
|
|
119
|
+
const suiKeypair = getSuiKeypair();
|
|
120
|
+
if (suiKeypair) {
|
|
121
|
+
console.error(`[tusky-mcp] Sui wallet loaded (${suiKeypair.toSuiAddress()}) — shared vault operations available.`);
|
|
122
|
+
} else {
|
|
123
|
+
console.error('[tusky-mcp] No TUSKYDP_SUI_PRIVATE_KEY — shared vault encrypt/decrypt unavailable.');
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
// Build context
|
|
117
127
|
const ctx: McpContext = {
|
|
118
128
|
sdk,
|
|
119
129
|
getMasterKey: () => masterKey,
|
|
120
130
|
isEncryptionReady: () => masterKey !== null,
|
|
131
|
+
getSuiKeypair: () => suiKeypair,
|
|
132
|
+
isSealReady: () => suiKeypair !== null,
|
|
121
133
|
};
|
|
122
134
|
|
|
123
135
|
// Create MCP server
|
|
@@ -132,6 +144,7 @@ export async function startMcpServer(options: McpServerOptions): Promise<void> {
|
|
|
132
144
|
registerFolderTools(server, ctx);
|
|
133
145
|
registerFileTools(server, ctx);
|
|
134
146
|
registerTrashTools(server, ctx);
|
|
147
|
+
registerSharedVaultTools(server, ctx);
|
|
135
148
|
|
|
136
149
|
// Connect via stdio transport
|
|
137
150
|
const transport = new StdioServerTransport();
|
package/src/mcp/tools/files.ts
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* MCP tools — File operations
|
|
3
3
|
*
|
|
4
4
|
* Handles upload with client-side encryption for private vaults,
|
|
5
|
-
*
|
|
5
|
+
* SEAL encryption for shared vaults, download with decryption and
|
|
6
|
+
* rehydration polling for cold files.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { z } from 'zod';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
9
11
|
import { readFileSync, writeFileSync, statSync } from 'fs';
|
|
10
12
|
import { basename, resolve, join } from 'path';
|
|
11
13
|
import { lookup } from 'mime-types';
|
|
12
14
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
15
|
import type { McpContext } from '../context.js';
|
|
14
16
|
import { encryptBuffer, decryptBuffer } from '../../crypto.js';
|
|
17
|
+
import { isSealConfigured, sealEncrypt, sealDecrypt } from '../../seal.js';
|
|
15
18
|
import { wrapToolError } from './helpers.js';
|
|
16
19
|
|
|
17
20
|
// ---------------------------------------------------------------------------
|
|
@@ -76,22 +79,39 @@ async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
|
|
|
76
79
|
const arrayBuf = await response.arrayBuffer();
|
|
77
80
|
let fileBuffer: Buffer = Buffer.from(arrayBuf as ArrayBuffer);
|
|
78
81
|
|
|
79
|
-
// Decrypt if needed
|
|
82
|
+
// Decrypt if needed — dispatch on encryption type
|
|
80
83
|
if (encryption?.encrypted) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
if ('type' in encryption && encryption.type === 'seal') {
|
|
85
|
+
// SEAL-encrypted (shared vault)
|
|
86
|
+
const keypair = ctx.getSuiKeypair();
|
|
87
|
+
if (!keypair) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'Sui wallet key required to decrypt shared vault files. ' +
|
|
90
|
+
'Set the TUSKYDP_SUI_PRIVATE_KEY environment variable in your MCP server config.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const fileInfo = await ctx.sdk.files.get(fileId);
|
|
94
|
+
const vault = await ctx.sdk.vaults.get(fileInfo.vaultId);
|
|
95
|
+
const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
|
|
96
|
+
fileBuffer = Buffer.from(decrypted);
|
|
97
|
+
} else {
|
|
98
|
+
// Passphrase-encrypted (private vault)
|
|
99
|
+
const masterKey = ctx.getMasterKey();
|
|
100
|
+
if (!masterKey) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Encryption passphrase required to decrypt this file. ' +
|
|
103
|
+
'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
|
|
107
|
+
fileBuffer = decryptBuffer(
|
|
108
|
+
fileBuffer,
|
|
109
|
+
enc.wrappedKey,
|
|
110
|
+
enc.iv,
|
|
111
|
+
masterKey,
|
|
112
|
+
enc.plaintextChecksumSha256 ?? undefined,
|
|
86
113
|
);
|
|
87
114
|
}
|
|
88
|
-
fileBuffer = decryptBuffer(
|
|
89
|
-
fileBuffer,
|
|
90
|
-
encryption.wrappedKey!,
|
|
91
|
-
encryption.iv!,
|
|
92
|
-
masterKey,
|
|
93
|
-
encryption.plaintextChecksumSha256 ?? undefined,
|
|
94
|
-
);
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
return { fileBuffer, encryption };
|
|
@@ -111,6 +131,7 @@ async function encryptAndUpload(
|
|
|
111
131
|
) {
|
|
112
132
|
const vault = await ctx.sdk.vaults.get(vaultId);
|
|
113
133
|
const isPrivate = vault.visibility === 'private';
|
|
134
|
+
const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
|
|
114
135
|
|
|
115
136
|
let uploadBody: Buffer;
|
|
116
137
|
let encryptionMeta: {
|
|
@@ -118,6 +139,8 @@ async function encryptAndUpload(
|
|
|
118
139
|
encryptionIv?: string;
|
|
119
140
|
plaintextSizeBytes?: number;
|
|
120
141
|
plaintextChecksumSha256?: string;
|
|
142
|
+
sealIdentity?: string;
|
|
143
|
+
sealEncryptedObject?: string;
|
|
121
144
|
} = {};
|
|
122
145
|
|
|
123
146
|
if (isPrivate) {
|
|
@@ -137,6 +160,23 @@ async function encryptAndUpload(
|
|
|
137
160
|
plaintextSizeBytes: fileBuffer.length,
|
|
138
161
|
plaintextChecksumSha256: plaintextChecksum,
|
|
139
162
|
};
|
|
163
|
+
} else if (isShared) {
|
|
164
|
+
const keypair = ctx.getSuiKeypair();
|
|
165
|
+
if (!keypair) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
'Sui wallet key required for shared vault uploads (SEAL encryption). ' +
|
|
168
|
+
'Set the TUSKYDP_SUI_PRIVATE_KEY environment variable in your MCP server config.',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fileNonce = randomUUID();
|
|
173
|
+
const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
|
|
174
|
+
uploadBody = Buffer.from(sealResult.encryptedData);
|
|
175
|
+
encryptionMeta = {
|
|
176
|
+
sealIdentity: sealResult.sealIdentity,
|
|
177
|
+
sealEncryptedObject: sealResult.sealEncryptedObject,
|
|
178
|
+
plaintextSizeBytes: fileBuffer.length,
|
|
179
|
+
};
|
|
140
180
|
} else {
|
|
141
181
|
uploadBody = fileBuffer;
|
|
142
182
|
}
|
|
@@ -165,7 +205,7 @@ async function encryptAndUpload(
|
|
|
165
205
|
// Confirm upload
|
|
166
206
|
const file = await ctx.sdk.files.confirmUpload(fileId);
|
|
167
207
|
|
|
168
|
-
return { file, isPrivate, uploadedSizeBytes: uploadBody.length };
|
|
208
|
+
return { file, isPrivate, isShared, uploadedSizeBytes: uploadBody.length };
|
|
169
209
|
}
|
|
170
210
|
|
|
171
211
|
// ---------------------------------------------------------------------------
|
|
@@ -201,7 +241,7 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
|
|
|
201
241
|
const fileName = basename(resolvedPath);
|
|
202
242
|
const mimeType = lookup(resolvedPath) || 'application/octet-stream';
|
|
203
243
|
|
|
204
|
-
const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
|
|
244
|
+
const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
|
|
205
245
|
fileBuffer, fileName, mimeType, vaultId, folderId, ctx,
|
|
206
246
|
);
|
|
207
247
|
|
|
@@ -209,7 +249,8 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
|
|
|
209
249
|
content: [{ type: 'text' as const, text: JSON.stringify({
|
|
210
250
|
...file,
|
|
211
251
|
localPath: resolvedPath,
|
|
212
|
-
encrypted: isPrivate,
|
|
252
|
+
encrypted: isPrivate || isShared,
|
|
253
|
+
encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
|
|
213
254
|
uploadedSizeBytes,
|
|
214
255
|
}, null, 2) }],
|
|
215
256
|
};
|
|
@@ -241,14 +282,15 @@ export function registerFileTools(server: McpServer, ctx: McpContext) {
|
|
|
241
282
|
|
|
242
283
|
const mimeType = lookup(name) || 'application/octet-stream';
|
|
243
284
|
|
|
244
|
-
const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
|
|
285
|
+
const { file, isPrivate, isShared, uploadedSizeBytes } = await encryptAndUpload(
|
|
245
286
|
fileBuffer, name, mimeType, vaultId, folderId, ctx,
|
|
246
287
|
);
|
|
247
288
|
|
|
248
289
|
return {
|
|
249
290
|
content: [{ type: 'text' as const, text: JSON.stringify({
|
|
250
291
|
...file,
|
|
251
|
-
encrypted: isPrivate,
|
|
292
|
+
encrypted: isPrivate || isShared,
|
|
293
|
+
encryptionType: isPrivate ? 'passphrase' : isShared ? 'seal' : 'none',
|
|
252
294
|
uploadedSizeBytes,
|
|
253
295
|
}, null, 2) }],
|
|
254
296
|
};
|