@andespindola/brainlink 1.8.2 → 1.10.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/README.md +14 -10
- package/dist/application/server/http.js +5 -0
- package/dist/application/server/routes.js +8 -8
- package/dist/application/vault-bundle.js +37 -8
- package/dist/application/vault-snapshot.js +53 -9
- package/dist/cli/commands/vault-sync-commands.js +13 -8
- package/dist/cli/runtime.js +6 -1
- package/dist/domain/note-safety.js +41 -14
- package/dist/infrastructure/context-packs.js +5 -2
- package/dist/infrastructure/vault-crypto.js +4 -0
- package/dist/mcp/server.js +11 -1
- package/dist/mcp/tools/vault-tools.js +92 -9
- package/dist/mcp/tools.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -562,8 +562,9 @@ Available tools:
|
|
|
562
562
|
- `brainlink_sync`: run index, stats, validation, broken-link and orphan checks in one call.
|
|
563
563
|
- `brainlink_vault_provision`: create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as `origin`, push the first snapshot, and enable `autoVersion` so later indexed changes are committed and pushed automatically. By default the repository is **encrypted** (`vaultEncryption`): it holds AES-256-GCM ciphertext, decryptable only by a Brainlink instance with the local key.
|
|
564
564
|
- `brainlink_vault_status` / `brainlink_vault_init` / `brainlink_vault_commit` / `brainlink_vault_history` / `brainlink_vault_pull` / `brainlink_vault_restore`: version the vault's canonical Markdown with git (the derived index is git-ignored). Pull and restore reindex changed files automatically so the vault stays immediately searchable.
|
|
565
|
-
- `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
|
|
566
|
-
- `brainlink_vault_export_bundle` / `brainlink_vault_bundle_list` / `brainlink_vault_import_bundle`: export **every known vault** (the config vault plus `allowedVaults`) into a single portable `.blbundle`, inspect the vaults inside a bundle, and import them back — all vaults to their recorded paths, or a single one via `only` (optionally into `targetVault`). Same checksum verification and conflict preservation as the single-vault snapshot.
|
|
565
|
+
- `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Encrypted with AES-256-GCM when a `passphrase` (or `BRAINLINK_VAULT_KEY`) is available, otherwise plaintext gzip; import auto-detects and decrypts. Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
|
|
566
|
+
- `brainlink_vault_export_bundle` / `brainlink_vault_bundle_list` / `brainlink_vault_import_bundle`: export **every known vault** (the config vault plus `allowedVaults`) into a single portable `.blbundle`, inspect the vaults inside a bundle, and import them back — all vaults to their recorded paths, or a single one via `only` (optionally into `targetVault`). Same encryption (via `passphrase`/`BRAINLINK_VAULT_KEY`), checksum verification and conflict preservation as the single-vault snapshot.
|
|
567
|
+
- `brainlink_vault_transfer_export` / `brainlink_vault_transfer_import`: move a vault **between two Brainlinks over MCP** without a file on disk. An agent connected to both servers calls `transfer_export` on the source (returns the vault as an encrypted, base64 payload) and `transfer_import` on the target (applies the payload and reindexes). A shared `passphrase` (min 8 chars) is mandatory since the payload travels through the agent's context. Same checksum verification and `*.conflict-<ts>.md` preservation as snapshot import.
|
|
567
568
|
- `brainlink_vault_merge_agents`: losslessly merge one agent namespace into another within the same vault (e.g. `claude-code` → `shared`). Identical notes are skipped, colliding slugs with different content are kept under a `-from-<agent>` name, everything else is moved, and merged notes are aggregated into domain `<context> Hub` notes. Source notes are kept unless `deleteSource` is set; `dryRun` previews the plan.
|
|
568
569
|
- `brainlink_graph`: read indexed graph nodes and weighted links.
|
|
569
570
|
- `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
|
|
@@ -884,7 +885,7 @@ blink add "Note Title" --vault ./vault --content-file ./notes.md --no-auto-index
|
|
|
884
885
|
|
|
885
886
|
`--content` and `--content-file` are mutually exclusive. Add `--no-auto-index` when you want to defer reindexing.
|
|
886
887
|
|
|
887
|
-
Creates a Markdown note under `agents/<agent-id>/`.
|
|
888
|
+
Creates a Markdown note under `agents/<agent-id>/`. Secrets (tokens, keys, passwords) can be stored freely — Brainlink is the canonical secret store — but recognized secrets are **masked** at user-facing read surfaces (the CLI and the graph web UI). MCP tool responses are unmasked so a consulting agent receives the real value.
|
|
888
889
|
To avoid disconnected memory, Brainlink auto-adds a fallback wiki edge when a note is written without links, creating agent hub notes when needed.
|
|
889
890
|
`add` also returns `possibleDuplicates` (exact hash + semantic candidates) so agents can resolve duplicate memory right after writes.
|
|
890
891
|
|
|
@@ -977,13 +978,15 @@ blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build in
|
|
|
977
978
|
blink vault-status
|
|
978
979
|
|
|
979
980
|
# Portable snapshot (self-contained, index excluded; import rebuilds it)
|
|
980
|
-
|
|
981
|
-
blink vault-
|
|
981
|
+
# Encrypted (AES-256-GCM) with a passphrase; without one it warns and writes plaintext gzip.
|
|
982
|
+
blink vault-export --passphrase "$PHRASE" -o vault.blvault
|
|
983
|
+
blink vault-import vault.blvault --passphrase "$PHRASE" # conflicting local edits preserved as *.conflict-<ts>.md
|
|
984
|
+
blink vault-export --no-encrypt -o vault.blvault # force legacy plaintext gzip
|
|
982
985
|
|
|
983
986
|
# Multi-vault bundle: every known vault (config vault + allowedVaults) in one file
|
|
984
|
-
blink vault-export --all -o vaults.blbundle
|
|
985
|
-
blink vault-import vaults.blbundle # restore all vaults to their recorded paths
|
|
986
|
-
blink vault-import vaults.blbundle --only shared --vault ./restored # restore just one
|
|
987
|
+
blink vault-export --all --passphrase "$PHRASE" -o vaults.blbundle
|
|
988
|
+
blink vault-import vaults.blbundle --passphrase "$PHRASE" # restore all vaults to their recorded paths
|
|
989
|
+
blink vault-import vaults.blbundle --only shared --vault ./restored --passphrase "$PHRASE" # restore just one
|
|
987
990
|
```
|
|
988
991
|
|
|
989
992
|
Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
|
|
@@ -1476,8 +1479,9 @@ Brainlink is local-first by default.
|
|
|
1476
1479
|
|
|
1477
1480
|
- Do not expose the HTTP server publicly without authentication.
|
|
1478
1481
|
- Brainlink HTTP is localhost-only and refuses non-loopback hosts.
|
|
1479
|
-
-
|
|
1480
|
-
-
|
|
1482
|
+
- Secrets stored in notes are masked at the CLI and graph web UI, but delivered unmasked to MCP agents that consult them. Masking is a display safeguard, not access control — anyone with filesystem access to the vault (or MCP access) can read the plaintext.
|
|
1483
|
+
- Notes are stored as plaintext Markdown on disk. Keep the vault on an encrypted volume and/or use encrypted vault versioning (`vaultEncryption`) and encrypted snapshots (`--passphrase`) for at-rest protection of regulated data.
|
|
1484
|
+
- Context packs, the JSON index and search packs cache note content; they are written owner-only (`0o600`) but are not a substitute for disk encryption.
|
|
1481
1485
|
- Treat `.brainlink/index.json` and `.brainlink/search-packs/` as disposable derived artifacts.
|
|
1482
1486
|
|
|
1483
1487
|
See [SECURITY.md](SECURITY.md).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maskSensitiveDeep } from '../../domain/note-safety.js';
|
|
1
2
|
export const contentTypes = {
|
|
2
3
|
'.html': 'text/html; charset=utf-8',
|
|
3
4
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -5,6 +6,10 @@ export const contentTypes = {
|
|
|
5
6
|
'.json': 'application/json; charset=utf-8'
|
|
6
7
|
};
|
|
7
8
|
export const createJsonResponse = (value) => JSON.stringify(value, null, 2);
|
|
9
|
+
// The graph web UI is a user-facing surface: any secret stored in a note is
|
|
10
|
+
// masked before it reaches the browser. Use this for content-bearing endpoints
|
|
11
|
+
// (graph/search/context); auth and control endpoints keep createJsonResponse.
|
|
12
|
+
export const createMaskedJsonResponse = (value) => JSON.stringify(maskSensitiveDeep(value), null, 2);
|
|
8
13
|
export const parsePositiveInteger = (value, fallback) => {
|
|
9
14
|
const parsed = Number.parseInt(value ?? '', 10);
|
|
10
15
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -30,7 +30,7 @@ import { readAuthStatus, readEditableSettings, updateEditableSettings } from './
|
|
|
30
30
|
import { connectVaultRemote, ensureDeployKey, readVersioningStatus } from './versioning.js';
|
|
31
31
|
import { createSettingsPageHtml } from '../frontend/settings-page.js';
|
|
32
32
|
import { createConnectPageHtml } from '../frontend/connect-page.js';
|
|
33
|
-
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
33
|
+
import { contentTypes, createJsonResponse, createMaskedJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
34
34
|
import { parseMultipartForm } from './multipart.js';
|
|
35
35
|
const readRuntimeDefaults = async (url) => {
|
|
36
36
|
const config = await loadBrainlinkConfig();
|
|
@@ -423,7 +423,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
423
423
|
return createResponse(readClientRenderWorkerJs(), 200, contentTypes['.js']);
|
|
424
424
|
}
|
|
425
425
|
if (isReadMethod(request) && url.pathname === '/api/graph') {
|
|
426
|
-
return createResponse(
|
|
426
|
+
return createResponse(createMaskedJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
427
427
|
}
|
|
428
428
|
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
429
429
|
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
@@ -444,7 +444,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
446
|
const cachedBody = readGraphLayoutBody(signature);
|
|
447
|
-
const body = cachedBody ??
|
|
447
|
+
const body = cachedBody ?? createMaskedJsonResponse({ signature, ...encodeLayoutPayload(layout) });
|
|
448
448
|
if (!cachedBody) {
|
|
449
449
|
storeGraphLayoutBody(signature, body);
|
|
450
450
|
}
|
|
@@ -458,7 +458,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
458
458
|
};
|
|
459
459
|
}
|
|
460
460
|
if (isReadMethod(request) && url.pathname === '/api/graph-view') {
|
|
461
|
-
return createResponse(
|
|
461
|
+
return createResponse(createMaskedJsonResponse(await getGraphView(vaultPath, {
|
|
462
462
|
x: parseNumber(url.searchParams.get('x'), -1000),
|
|
463
463
|
y: parseNumber(url.searchParams.get('y'), -1000),
|
|
464
464
|
width: parseNumber(url.searchParams.get('w'), 2000),
|
|
@@ -476,7 +476,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
476
476
|
const scale = parseNumber(url.searchParams.get('scale'), 0.24);
|
|
477
477
|
const nodeBudget = parsePositiveInteger(url.searchParams.get('nodeBudget'), 1800);
|
|
478
478
|
const edgeBudget = parsePositiveInteger(url.searchParams.get('edgeBudget'), 5000);
|
|
479
|
-
return createResponse(
|
|
479
|
+
return createResponse(createMaskedJsonResponse(await getGraphStreamChunk(vaultPath, {
|
|
480
480
|
x,
|
|
481
481
|
y,
|
|
482
482
|
width,
|
|
@@ -554,7 +554,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
554
554
|
if (!node) {
|
|
555
555
|
return createResponse(createJsonResponse({ error: 'Node not found' }), 404, contentTypes['.json']);
|
|
556
556
|
}
|
|
557
|
-
return createResponse(
|
|
557
|
+
return createResponse(createMaskedJsonResponse({ node }), 200, contentTypes['.json']);
|
|
558
558
|
}
|
|
559
559
|
if (isReadMethod(request) && url.pathname === '/api/graph-filter') {
|
|
560
560
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
|
@@ -625,7 +625,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
625
625
|
if (hasInvalidSearchMode(url)) {
|
|
626
626
|
return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
|
|
627
627
|
}
|
|
628
|
-
return createResponse(
|
|
628
|
+
return createResponse(createMaskedJsonResponse({ query, agent: readAgentQuery(url), limit, mode, results: await searchKnowledge(vaultPath, query, limit, readAgentQuery(url), mode) }), 200, contentTypes['.json']);
|
|
629
629
|
}
|
|
630
630
|
if (isReadMethod(request) && url.pathname === '/api/context') {
|
|
631
631
|
const query = url.searchParams.get('q') ?? '';
|
|
@@ -642,7 +642,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
642
642
|
if (hasInvalidContextStrategy(url)) {
|
|
643
643
|
return createResponse(createJsonResponse({ error: 'Invalid strategy. Use rag, cag or auto.' }), 400, contentTypes['.json']);
|
|
644
644
|
}
|
|
645
|
-
return createResponse(
|
|
645
|
+
return createResponse(createMaskedJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs, cagPackTtlMs)), 200, contentTypes['.json']);
|
|
646
646
|
}
|
|
647
647
|
if (isReadMethod(request) && url.pathname === '/api/links') {
|
|
648
648
|
return createResponse(createJsonResponse({ links: await listLinks(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
// snapshot per vault, built on the reusable single-vault snapshot helpers. Exports
|
|
3
3
|
// all known vaults (or a chosen subset) into one movable file; import restores every
|
|
4
4
|
// bundled vault back to its recorded path or a chosen target, preserving conflicts.
|
|
5
|
+
// Because a bundle spans multiple vaults it has no single per-vault key, so it is
|
|
6
|
+
// encrypted (AES-256-GCM) whenever a passphrase (or BRAINLINK_VAULT_KEY) is
|
|
7
|
+
// available; without one it falls back to legacy plaintext gzip with a warning.
|
|
8
|
+
// Legacy plaintext bundles still import (auto-detected).
|
|
5
9
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
6
10
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
7
11
|
import { basename, resolve } from 'node:path';
|
|
8
12
|
import { buildSnapshotEnvelope, applySnapshotEnvelope } from './vault-snapshot.js';
|
|
13
|
+
import { decryptVaultContent, deriveVaultKeyFromPassphrase, encryptVaultContent, isEncryptedVaultPayload } from '../infrastructure/vault-crypto.js';
|
|
9
14
|
const bundleVersion = 1;
|
|
10
15
|
const bundleKind = 'brainlink-vault-bundle';
|
|
16
|
+
// A bundle key comes only from a passphrase or BRAINLINK_VAULT_KEY (no per-vault
|
|
17
|
+
// keyfile, since a bundle spans vaults). Returns null when neither is available.
|
|
18
|
+
const resolveBundleKey = (passphrase) => {
|
|
19
|
+
const secret = (passphrase && passphrase.length > 0 ? passphrase : process.env.BRAINLINK_VAULT_KEY?.trim()) ?? '';
|
|
20
|
+
return secret.length > 0 ? deriveVaultKeyFromPassphrase(secret) : null;
|
|
21
|
+
};
|
|
11
22
|
const dedupePaths = (vaultPaths) => {
|
|
12
23
|
const seen = new Set();
|
|
13
24
|
const paths = [];
|
|
@@ -20,14 +31,27 @@ const dedupePaths = (vaultPaths) => {
|
|
|
20
31
|
}
|
|
21
32
|
return paths;
|
|
22
33
|
};
|
|
23
|
-
const parseBundle = (raw) => {
|
|
24
|
-
|
|
34
|
+
const parseBundle = (raw, passphrase) => {
|
|
35
|
+
let gzipped = raw;
|
|
36
|
+
if (isEncryptedVaultPayload(raw)) {
|
|
37
|
+
const key = resolveBundleKey(passphrase);
|
|
38
|
+
if (!key) {
|
|
39
|
+
throw new Error('Encrypted .blbundle requires --passphrase (or BRAINLINK_VAULT_KEY).');
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
gzipped = decryptVaultContent(key, raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error('Encrypted .blbundle could not be decrypted. Check the --passphrase / BRAINLINK_VAULT_KEY value.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const envelope = JSON.parse(gunzipSync(gzipped).toString('utf8'));
|
|
25
49
|
if (!envelope || envelope.kind !== bundleKind || envelope.version !== bundleVersion || !Array.isArray(envelope.vaults)) {
|
|
26
50
|
throw new Error('Unrecognized .blbundle format.');
|
|
27
51
|
}
|
|
28
52
|
return envelope;
|
|
29
53
|
};
|
|
30
|
-
export const exportVaultsBundle = async (vaultPaths, outputFile) => {
|
|
54
|
+
export const exportVaultsBundle = async (vaultPaths, outputFile, options = {}) => {
|
|
31
55
|
const uniquePaths = dedupePaths(vaultPaths);
|
|
32
56
|
const vaults = await Promise.all(uniquePaths.map(async (vaultPath) => {
|
|
33
57
|
const vault = resolve(vaultPath);
|
|
@@ -39,13 +63,18 @@ export const exportVaultsBundle = async (vaultPaths, outputFile) => {
|
|
|
39
63
|
createdAt: new Date().toISOString(),
|
|
40
64
|
vaults
|
|
41
65
|
};
|
|
42
|
-
const
|
|
66
|
+
const gzipped = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
67
|
+
const key = options.encrypt !== false ? resolveBundleKey(options.passphrase) : null;
|
|
68
|
+
if (options.encrypt !== false && !key) {
|
|
69
|
+
process.stderr.write('brainlink: bundle exported unencrypted — pass --passphrase (or set BRAINLINK_VAULT_KEY) to encrypt a portable bundle.\n');
|
|
70
|
+
}
|
|
71
|
+
const packed = key ? encryptVaultContent(key, gzipped) : gzipped;
|
|
43
72
|
await writeFile(outputFile, packed, { mode: 0o600 });
|
|
44
73
|
const fileCount = vaults.reduce((total, entry) => total + entry.snapshot.files.length, 0);
|
|
45
|
-
return { file: outputFile, vaultCount: vaults.length, fileCount, bytes: packed.byteLength };
|
|
74
|
+
return { file: outputFile, vaultCount: vaults.length, fileCount, bytes: packed.byteLength, encrypted: Boolean(key) };
|
|
46
75
|
};
|
|
47
|
-
export const listBundleVaults = async (bundleFile) => {
|
|
48
|
-
const envelope = parseBundle(await readFile(bundleFile));
|
|
76
|
+
export const listBundleVaults = async (bundleFile, passphrase) => {
|
|
77
|
+
const envelope = parseBundle(await readFile(bundleFile), passphrase);
|
|
49
78
|
return envelope.vaults.map((entry) => ({
|
|
50
79
|
name: entry.name,
|
|
51
80
|
vault: entry.vault,
|
|
@@ -53,7 +82,7 @@ export const listBundleVaults = async (bundleFile) => {
|
|
|
53
82
|
}));
|
|
54
83
|
};
|
|
55
84
|
export const importVaultsBundle = async (bundleFile, options = {}) => {
|
|
56
|
-
const envelope = parseBundle(await readFile(bundleFile));
|
|
85
|
+
const envelope = parseBundle(await readFile(bundleFile), options.passphrase);
|
|
57
86
|
const selected = options.only
|
|
58
87
|
? envelope.vaults.filter((entry) => entry.name === options.only || entry.vault === options.only)
|
|
59
88
|
: envelope.vaults;
|
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
// canonical Markdown (with per-file SHA-256), self-contained and movable to any
|
|
3
3
|
// machine. The derived index is never included — import rebuilds it so the vault
|
|
4
4
|
// is immediately usable. Conflicts on import are preserved as `*.conflict-<ts>.md`
|
|
5
|
-
// rather than overwriting local edits.
|
|
6
|
-
|
|
5
|
+
// rather than overwriting local edits. A portable artifact can only be encrypted
|
|
6
|
+
// with a shared secret, so the snapshot is AES-256-GCM encrypted whenever a
|
|
7
|
+
// passphrase (or BRAINLINK_VAULT_KEY) is available and falls back to legacy
|
|
8
|
+
// plaintext gzip (with a warning) otherwise. Legacy gzip snapshots still import
|
|
9
|
+
// (auto-detected); `--no-encrypt` forces plaintext even when a key is present.
|
|
7
10
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
8
11
|
import { extname, relative } from 'node:path';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
9
13
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
14
|
import { indexVault } from './index-vault.js';
|
|
11
15
|
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
16
|
+
import { decryptVaultContent, deriveVaultKeyFromPassphrase, encryptVaultContent, isEncryptedVaultPayload } from '../infrastructure/vault-crypto.js';
|
|
12
17
|
const snapshotVersion = 1;
|
|
18
|
+
// A snapshot key comes only from a passphrase or BRAINLINK_VAULT_KEY (never a
|
|
19
|
+
// per-vault keyfile, which would make the "portable" artifact undecryptable on
|
|
20
|
+
// another machine/path). Returns null when neither is available.
|
|
21
|
+
const resolveSnapshotKey = (passphrase) => {
|
|
22
|
+
const secret = (passphrase && passphrase.length > 0 ? passphrase : process.env.BRAINLINK_VAULT_KEY?.trim()) ?? '';
|
|
23
|
+
return secret.length > 0 ? deriveVaultKeyFromPassphrase(secret) : null;
|
|
24
|
+
};
|
|
13
25
|
const isMarkdown = (path) => extname(path).toLowerCase() === '.md';
|
|
14
26
|
const toPosix = (path) => path.split('\\').join('/');
|
|
15
27
|
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
@@ -37,14 +49,46 @@ export const buildSnapshotEnvelope = async (vaultPath) => {
|
|
|
37
49
|
files: entries.map((entry) => entry.file)
|
|
38
50
|
};
|
|
39
51
|
};
|
|
40
|
-
|
|
52
|
+
// Build the in-memory snapshot artifact (gzip envelope, optionally encrypted).
|
|
53
|
+
// Shared by the file-based export and the MCP payload transfer so both formats
|
|
54
|
+
// are byte-identical. `requireEncryption` refuses to emit plaintext (used by the
|
|
55
|
+
// transfer path, where the payload travels through an agent's context).
|
|
56
|
+
export const packVaultSnapshot = async (vaultPath, options = {}) => {
|
|
41
57
|
const envelope = await buildSnapshotEnvelope(vaultPath);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const gzipped = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
59
|
+
const key = options.encrypt !== false ? resolveSnapshotKey(options.passphrase) : null;
|
|
60
|
+
if (options.requireEncryption && !key) {
|
|
61
|
+
throw new Error('A passphrase (or BRAINLINK_VAULT_KEY) is required to produce an encrypted snapshot.');
|
|
62
|
+
}
|
|
63
|
+
const payload = key ? encryptVaultContent(key, gzipped) : gzipped;
|
|
64
|
+
return { payload, fileCount: envelope.files.length, encrypted: Boolean(key) };
|
|
65
|
+
};
|
|
66
|
+
// Import a snapshot straight from an in-memory payload (no file on disk). Used by
|
|
67
|
+
// the MCP vault-transfer import so a snapshot can move between Brainlinks.
|
|
68
|
+
export const applyVaultSnapshotPayload = async (payload, vaultPath, options = {}) => applySnapshotEnvelope(parseEnvelope(payload, options.passphrase), vaultPath, options);
|
|
69
|
+
export const exportVaultSnapshot = async (vaultPath, outputFile, options = {}) => {
|
|
70
|
+
const artifact = await packVaultSnapshot(vaultPath, options);
|
|
71
|
+
if (options.encrypt !== false && !artifact.encrypted) {
|
|
72
|
+
process.stderr.write('brainlink: snapshot exported unencrypted — pass --passphrase (or set BRAINLINK_VAULT_KEY) to encrypt a portable snapshot.\n');
|
|
73
|
+
}
|
|
74
|
+
await writeFile(outputFile, artifact.payload, { mode: 0o600 });
|
|
75
|
+
return { file: outputFile, fileCount: artifact.fileCount, bytes: artifact.payload.byteLength, encrypted: artifact.encrypted };
|
|
45
76
|
};
|
|
46
|
-
const parseEnvelope = (raw) => {
|
|
47
|
-
|
|
77
|
+
const parseEnvelope = (raw, passphrase) => {
|
|
78
|
+
let gzipped = raw;
|
|
79
|
+
if (isEncryptedVaultPayload(raw)) {
|
|
80
|
+
const key = resolveSnapshotKey(passphrase);
|
|
81
|
+
if (!key) {
|
|
82
|
+
throw new Error('Encrypted .blvault requires --passphrase (or BRAINLINK_VAULT_KEY).');
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
gzipped = decryptVaultContent(key, raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error('Encrypted .blvault could not be decrypted. Check the --passphrase / BRAINLINK_VAULT_KEY value.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const envelope = JSON.parse(gunzipSync(gzipped).toString('utf8'));
|
|
48
92
|
if (!envelope || envelope.version !== snapshotVersion || !Array.isArray(envelope.files)) {
|
|
49
93
|
throw new Error('Unrecognized .blvault snapshot format.');
|
|
50
94
|
}
|
|
@@ -88,6 +132,6 @@ export const applySnapshotEnvelope = async (envelope, vaultPath, options = {}) =
|
|
|
88
132
|
return { imported, conflicted, index };
|
|
89
133
|
};
|
|
90
134
|
export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {}) => {
|
|
91
|
-
const envelope = parseEnvelope(await readFile(snapshotFile));
|
|
135
|
+
const envelope = parseEnvelope(await readFile(snapshotFile), options.passphrase);
|
|
92
136
|
return applySnapshotEnvelope(envelope, vaultPath, options);
|
|
93
137
|
};
|
|
@@ -118,24 +118,28 @@ export const registerVaultSyncCommands = (program) => {
|
|
|
118
118
|
.option('-o, --output <file>', 'output file (.blvault for one vault, .blbundle with --all)')
|
|
119
119
|
.option('--vault <vault>', 'vault directory')
|
|
120
120
|
.option('--all', 'export every known vault (config vault + allowedVaults) into a single .blbundle')
|
|
121
|
+
.option('--passphrase <passphrase>', 'encrypt with a portable passphrase (decrypts on any machine that knows it)')
|
|
122
|
+
.option('--no-encrypt', 'export legacy plaintext gzip (portable but unencrypted)')
|
|
121
123
|
.option('--json', 'print machine-readable JSON')
|
|
122
|
-
.description('export vault Markdown to a portable snapshot: one vault (.blvault) or all vaults (--all, .blbundle)')
|
|
124
|
+
.description('export vault Markdown to a portable snapshot: one vault (.blvault) or all vaults (--all, .blbundle); encrypted by default')
|
|
123
125
|
.action(async (options) => {
|
|
124
126
|
const { config, vault } = await resolveOptions(options);
|
|
127
|
+
const crypto = { passphrase: options.passphrase, encrypt: options.encrypt };
|
|
125
128
|
if (options.all) {
|
|
126
129
|
const vaults = getKnownVaults(config);
|
|
127
|
-
const result = await exportVaultsBundle(vaults, options.output ?? 'vaults.blbundle');
|
|
128
|
-
print(options.json, result, () => `Exported ${result.vaultCount} vaults (${result.fileCount} files) to ${result.file} (${result.bytes} bytes).`);
|
|
130
|
+
const result = await exportVaultsBundle(vaults, options.output ?? 'vaults.blbundle', crypto);
|
|
131
|
+
print(options.json, result, () => `Exported ${result.vaultCount} vaults (${result.fileCount} files) to ${result.file} (${result.bytes} bytes, ${result.encrypted ? 'encrypted' : 'plaintext'}).`);
|
|
129
132
|
return;
|
|
130
133
|
}
|
|
131
|
-
const result = await exportVaultSnapshot(vault, options.output ?? 'vault.blvault');
|
|
132
|
-
print(options.json, result, () => `Exported ${result.fileCount} files to ${result.file} (${result.bytes} bytes).`);
|
|
134
|
+
const result = await exportVaultSnapshot(vault, options.output ?? 'vault.blvault', crypto);
|
|
135
|
+
print(options.json, result, () => `Exported ${result.fileCount} files to ${result.file} (${result.bytes} bytes, ${result.encrypted ? 'encrypted' : 'plaintext'}).`);
|
|
133
136
|
});
|
|
134
137
|
program
|
|
135
138
|
.command('vault-import')
|
|
136
139
|
.argument('<file>', 'input .blvault snapshot or .blbundle')
|
|
137
140
|
.option('--vault <vault>', 'target vault directory (single snapshot, or the target for --only)')
|
|
138
141
|
.option('--only <vault>', 'when importing a bundle, restore only this vault (by name or recorded path)')
|
|
142
|
+
.option('--passphrase <passphrase>', 'passphrase to decrypt an encrypted snapshot/bundle')
|
|
139
143
|
.option('--no-index', 'skip reindexing after import')
|
|
140
144
|
.option('--json', 'print machine-readable JSON')
|
|
141
145
|
.description('import a .blvault snapshot into a vault, or a .blbundle (all vaults, or one via --only)')
|
|
@@ -143,12 +147,13 @@ export const registerVaultSyncCommands = (program) => {
|
|
|
143
147
|
const skipIndex = options.index === false;
|
|
144
148
|
// Auto-detect a bundle so a single command handles both formats. --only is
|
|
145
149
|
// bundle-only, so it forces the bundle path.
|
|
146
|
-
const bundleEntries = await listBundleVaults(file).catch(() => null);
|
|
150
|
+
const bundleEntries = await listBundleVaults(file, options.passphrase).catch(() => null);
|
|
147
151
|
if (bundleEntries !== null || options.only !== undefined) {
|
|
148
152
|
const result = await importVaultsBundle(file, {
|
|
149
153
|
only: options.only,
|
|
150
154
|
targetVault: options.only !== undefined ? options.vault : undefined,
|
|
151
|
-
skipIndex
|
|
155
|
+
skipIndex,
|
|
156
|
+
passphrase: options.passphrase
|
|
152
157
|
});
|
|
153
158
|
print(options.json, result, () => result.vaults
|
|
154
159
|
.map((entry) => `${entry.name} → ${entry.vault}: imported ${entry.imported} (${entry.conflicted} conflicts)`)
|
|
@@ -156,7 +161,7 @@ export const registerVaultSyncCommands = (program) => {
|
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
158
163
|
const { vault } = await resolveOptions(options);
|
|
159
|
-
const result = await importVaultSnapshot(file, vault, { skipIndex });
|
|
164
|
+
const result = await importVaultSnapshot(file, vault, { skipIndex, passphrase: options.passphrase });
|
|
160
165
|
print(options.json, result, () => `Imported ${result.imported} files (${result.conflicted} conflicts)${result.index ? `; reindexed ${result.index.documentCount} docs` : ''}.`);
|
|
161
166
|
});
|
|
162
167
|
};
|
package/dist/cli/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoMigrateConfiguredVaultIfChanged } from '../application/auto-migrate-configured-vault.js';
|
|
2
|
+
import { maskSensitiveDeep, maskSensitiveText } from '../domain/note-safety.js';
|
|
2
3
|
import { loadBrainlinkConfigWithSource, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
|
|
3
4
|
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
4
5
|
export const parsePositiveInteger = (value, fallback) => {
|
|
@@ -25,6 +26,10 @@ export const resolveOptions = async (options) => {
|
|
|
25
26
|
defaults
|
|
26
27
|
};
|
|
27
28
|
};
|
|
29
|
+
// The CLI is a user-facing surface, so any secret stored in a note is masked
|
|
30
|
+
// here before it reaches the terminal — both for `--json` (deep-masked object)
|
|
31
|
+
// and human-readable output (masked rendered string). MCP tool responses go
|
|
32
|
+
// through a separate path (jsonResult) and stay unmasked for agents.
|
|
28
33
|
export const print = (json, value, human) => {
|
|
29
|
-
console.log(json ? JSON.stringify(value, null, 2) : human());
|
|
34
|
+
console.log(json ? JSON.stringify(maskSensitiveDeep(value), null, 2) : maskSensitiveText(human()));
|
|
30
35
|
};
|
|
@@ -1,38 +1,70 @@
|
|
|
1
1
|
const maxTitleLength = 160;
|
|
2
2
|
const maxContentLength = 200_000;
|
|
3
|
-
const
|
|
3
|
+
const mask = '••••••••';
|
|
4
|
+
const sensitiveRules = [
|
|
4
5
|
{
|
|
5
6
|
label: 'private key',
|
|
6
|
-
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY
|
|
7
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
8
|
+
mask: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
9
|
+
replace: () => '[redacted: private key]'
|
|
7
10
|
},
|
|
8
11
|
{
|
|
9
12
|
label: 'OpenAI API key',
|
|
10
|
-
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b
|
|
13
|
+
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
14
|
+
mask: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
15
|
+
replace: () => `sk-${mask}`
|
|
11
16
|
},
|
|
12
17
|
{
|
|
13
18
|
label: 'GitHub token',
|
|
14
|
-
pattern: /\b(?:ghp|gho|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b
|
|
19
|
+
pattern: /\b(?:ghp|gho|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/,
|
|
20
|
+
mask: /\b((?:ghp|gho|ghs|ghr|github_pat)_)[A-Za-z0-9_]{20,}\b/g,
|
|
21
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
15
22
|
},
|
|
16
23
|
{
|
|
17
24
|
label: 'AWS access key',
|
|
18
|
-
pattern: /\bAKIA[0-9A-Z]{16}\b
|
|
25
|
+
pattern: /\bAKIA[0-9A-Z]{16}\b/,
|
|
26
|
+
mask: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
27
|
+
replace: () => `AKIA${mask}`
|
|
19
28
|
},
|
|
20
29
|
{
|
|
21
30
|
label: 'Slack token',
|
|
22
|
-
pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b
|
|
31
|
+
pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b/,
|
|
32
|
+
mask: /\b(xox[abprs]-)[A-Za-z0-9-]{20,}\b/g,
|
|
33
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
23
34
|
},
|
|
24
35
|
{
|
|
25
36
|
label: 'JWT',
|
|
26
|
-
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b
|
|
37
|
+
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
38
|
+
mask: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
39
|
+
replace: () => '[redacted: JWT]'
|
|
27
40
|
},
|
|
28
41
|
{
|
|
29
42
|
label: 'secret assignment',
|
|
30
|
-
pattern: /\b(?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i
|
|
43
|
+
pattern: /\b(?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i,
|
|
44
|
+
mask: /\b((?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?)[A-Za-z0-9_./+=-]{12,}/gi,
|
|
45
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
31
46
|
}
|
|
32
47
|
];
|
|
33
|
-
export const findSensitiveContent = (value) =>
|
|
48
|
+
export const findSensitiveContent = (value) => sensitiveRules
|
|
34
49
|
.filter(({ pattern }) => pattern.test(value))
|
|
35
50
|
.map(({ label }) => ({ label }));
|
|
51
|
+
// Replace every recognized secret in a string with a masked marker, preserving
|
|
52
|
+
// surrounding text. Used only at user-facing surfaces (CLI, graph web UI).
|
|
53
|
+
export const maskSensitiveText = (value) => sensitiveRules.reduce((masked, rule) => masked.replace(rule.mask, rule.replace), value);
|
|
54
|
+
// Deep-mask every string in an arbitrary JSON-serializable value. Returns a new
|
|
55
|
+
// structure; the original (used to answer MCP agents) is never mutated.
|
|
56
|
+
export const maskSensitiveDeep = (value) => {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
return maskSensitiveText(value);
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.map((item) => maskSensitiveDeep(item));
|
|
62
|
+
}
|
|
63
|
+
if (value !== null && typeof value === 'object') {
|
|
64
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskSensitiveDeep(item)]));
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
};
|
|
36
68
|
export const validateNoteInput = (input) => {
|
|
37
69
|
if (!input.title.trim()) {
|
|
38
70
|
throw new Error('Note title cannot be empty.');
|
|
@@ -46,9 +78,4 @@ export const validateNoteInput = (input) => {
|
|
|
46
78
|
if (input.content.length > maxContentLength) {
|
|
47
79
|
throw new Error(`Note content is too large. Maximum length is ${maxContentLength} characters.`);
|
|
48
80
|
}
|
|
49
|
-
const findings = findSensitiveContent(`${input.title}\n${input.content}`);
|
|
50
|
-
if (findings.length > 0 && !input.allowSensitive) {
|
|
51
|
-
const labels = Array.from(new Set(findings.map((finding) => finding.label))).join(', ');
|
|
52
|
-
throw new Error(`Sensitive memory blocked (${labels}). Remove secrets or pass --allow-sensitive intentionally.`);
|
|
53
|
-
}
|
|
54
81
|
};
|
|
@@ -155,7 +155,10 @@ export const writeContextPack = async (vaultPath, key, context, meta) => {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
};
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// Context packs cache raw note/chunk content in plaintext, so keep them
|
|
159
|
+
// owner-only (0o700 dir, 0o600 file) to match the rest of the vault-on-disk
|
|
160
|
+
// hardening rather than inheriting a world-readable umask default.
|
|
161
|
+
await mkdir(contextPacksDirectory(vaultPath), { recursive: true, mode: 0o700 });
|
|
162
|
+
await writeFile(path, `${JSON.stringify(stored, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
160
163
|
return path;
|
|
161
164
|
};
|
|
@@ -17,6 +17,10 @@ const keyFilePath = (vaultPath) => {
|
|
|
17
17
|
return join(getBrainlinkHomePath(), 'keys', `vault-versioning-${vaultHash}.key`);
|
|
18
18
|
};
|
|
19
19
|
const deriveKeyFromSecret = (secret) => createHash('sha256').update(secret, 'utf8').digest();
|
|
20
|
+
// Derive a 32-byte AES key from a user-supplied passphrase. Used for portable
|
|
21
|
+
// encrypted snapshots/bundles that must decrypt on another machine carrying only
|
|
22
|
+
// the passphrase (not the per-vault keyfile).
|
|
23
|
+
export const deriveVaultKeyFromPassphrase = (passphrase) => deriveKeyFromSecret(passphrase);
|
|
20
24
|
// Load the vault's versioning key, generating a fresh 48-byte secret on first
|
|
21
25
|
// use. BRAINLINK_VAULT_KEY overrides the file (useful to restore an encrypted
|
|
22
26
|
// vault on another machine by carrying just the secret, not the whole repo).
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { addNoteInputSchema, addFileInputSchema, addFileTool, addImageInputSchema, addImageTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, getImageInputSchema, getImageTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, versionInputSchema, versionTool } from './tools.js';
|
|
2
|
+
import { addNoteInputSchema, addFileInputSchema, addFileTool, addImageInputSchema, addImageTool, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, brokenLinksInputSchema, brokenLinksTool, bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, getImageInputSchema, getImageTool, explainInputSchema, explainTool, indexInputSchema, indexTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, orphansInputSchema, orphansTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, recommendationsInputSchema, recommendationsTool, rememberInputSchema, rememberTool, repairLinksInputSchema, repairLinksTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, sessionCloseInputSchema, sessionCloseTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, syncInputSchema, syncTool, validateInputSchema, validateTool, vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, vaultTransferExportInputSchema, vaultTransferExportTool, vaultTransferImportInputSchema, vaultTransferImportTool, versionInputSchema, versionTool } from './tools.js';
|
|
3
3
|
import { getRuntimeVersion } from './runtime.js';
|
|
4
4
|
import { brainlinkServerInstructions } from './server-instructions.js';
|
|
5
5
|
import { guardToolHandler } from './tool-guard.js';
|
|
@@ -231,6 +231,16 @@ export const createBrainlinkMcpServer = () => {
|
|
|
231
231
|
description: 'Import a .blbundle: restore every vault to its recorded path, or a single one via "only" (optionally into "targetVault"). Checksums are verified; divergent local files are preserved as *.conflict-<ts>.md.',
|
|
232
232
|
inputSchema: vaultImportBundleInputSchema
|
|
233
233
|
}, vaultImportBundleTool);
|
|
234
|
+
server.registerTool('brainlink_vault_transfer_export', {
|
|
235
|
+
title: 'Export Vault for MCP Transfer',
|
|
236
|
+
description: 'Export this vault as an encrypted, base64 transfer payload (no file on disk) so an agent connected to two Brainlinks can move it. Requires a shared passphrase; import the payload on the target with brainlink_vault_transfer_import and the same passphrase.',
|
|
237
|
+
inputSchema: vaultTransferExportInputSchema
|
|
238
|
+
}, vaultTransferExportTool);
|
|
239
|
+
server.registerTool('brainlink_vault_transfer_import', {
|
|
240
|
+
title: 'Import Vault from MCP Transfer',
|
|
241
|
+
description: 'Import a base64 transfer payload produced by brainlink_vault_transfer_export on another Brainlink, using the same passphrase. Checksums are verified; divergent local files are preserved as *.conflict-<ts>.md, then the vault is reindexed.',
|
|
242
|
+
inputSchema: vaultTransferImportInputSchema
|
|
243
|
+
}, vaultTransferImportTool);
|
|
234
244
|
server.registerTool('brainlink_graph', {
|
|
235
245
|
title: 'Read Brainlink Graph',
|
|
236
246
|
description: 'Read indexed graph nodes and wiki-link edges. Edges include weight and priority fields so agents can rank importance and priority.',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { commitVault, initVaultGit, pullVault, pushVault, restoreVault, vaultGitStatus, vaultHistory } from '../../application/vault-git.js';
|
|
3
3
|
import { provisionVaultRepo } from '../../application/provision-vault.js';
|
|
4
|
-
import { exportVaultSnapshot, importVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
4
|
+
import { applyVaultSnapshotPayload, exportVaultSnapshot, importVaultSnapshot, packVaultSnapshot } from '../../application/vault-snapshot.js';
|
|
5
5
|
import { exportVaultsBundle, importVaultsBundle, listBundleVaults } from '../../application/vault-bundle.js';
|
|
6
6
|
import { mergeAgentNamespace } from '../../application/merge-agent-namespace.js';
|
|
7
7
|
import { getKnownVaults, loadBrainlinkConfig } from '../../infrastructure/config.js';
|
|
@@ -33,24 +33,59 @@ export const vaultRestoreInputSchema = {
|
|
|
33
33
|
};
|
|
34
34
|
export const vaultExportInputSchema = {
|
|
35
35
|
...vaultInput,
|
|
36
|
-
outputFile: z.string().min(1).describe('Path to write the portable .blvault snapshot to.')
|
|
36
|
+
outputFile: z.string().min(1).describe('Path to write the portable .blvault snapshot to.'),
|
|
37
|
+
passphrase: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('Encrypt with a portable passphrase (decrypts on any machine that knows it). Omit to use the per-vault key.'),
|
|
42
|
+
encrypt: z.boolean().optional().default(true).describe('Encrypt the snapshot (AES-256-GCM). Set false for a legacy plaintext gzip snapshot.')
|
|
37
43
|
};
|
|
38
44
|
export const vaultImportInputSchema = {
|
|
39
45
|
...vaultInput,
|
|
40
46
|
snapshotFile: z.string().min(1).describe('Path to a .blvault snapshot to import. Divergent local files are preserved as *.conflict-<ts>.md.'),
|
|
47
|
+
passphrase: z.string().min(1).optional().describe('Passphrase to decrypt an encrypted snapshot (if it was exported with one).'),
|
|
41
48
|
skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
|
|
42
49
|
};
|
|
43
50
|
export const vaultExportBundleInputSchema = {
|
|
44
51
|
...vaultInput,
|
|
45
|
-
outputFile: z.string().min(1).describe('Path to write the portable .blbundle to (contains every known vault).')
|
|
52
|
+
outputFile: z.string().min(1).describe('Path to write the portable .blbundle to (contains every known vault).'),
|
|
53
|
+
passphrase: z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Encrypt with a portable passphrase. Without it (or BRAINLINK_VAULT_KEY) the bundle is written as legacy plaintext gzip.'),
|
|
58
|
+
encrypt: z.boolean().optional().default(true).describe('Encrypt the bundle when a passphrase/key is available. Set false to force plaintext gzip.')
|
|
46
59
|
};
|
|
47
60
|
export const vaultBundleListInputSchema = {
|
|
48
|
-
bundleFile: z.string().min(1).describe('Path to a .blbundle to inspect.')
|
|
61
|
+
bundleFile: z.string().min(1).describe('Path to a .blbundle to inspect.'),
|
|
62
|
+
passphrase: z.string().min(1).optional().describe('Passphrase to decrypt an encrypted bundle before listing its vaults.')
|
|
49
63
|
};
|
|
50
64
|
export const vaultImportBundleInputSchema = {
|
|
51
65
|
bundleFile: z.string().min(1).describe('Path to a .blbundle to import. Divergent local files are preserved as *.conflict-<ts>.md.'),
|
|
52
66
|
only: z.string().min(1).optional().describe('Restore only this vault from the bundle (by name or recorded path). Omit to restore every vault.'),
|
|
53
67
|
targetVault: z.string().min(1).optional().describe('Target directory for the restored vault (only valid with "only"). Defaults to the recorded path.'),
|
|
68
|
+
passphrase: z.string().min(1).optional().describe('Passphrase to decrypt an encrypted bundle.'),
|
|
69
|
+
skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
|
|
70
|
+
};
|
|
71
|
+
// MCP-to-MCP vault transfer (agent-mediated): the agent, connected to both
|
|
72
|
+
// Brainlinks, exports an encrypted payload from the source and imports it into
|
|
73
|
+
// the target. The payload travels through the agent's context, so encryption
|
|
74
|
+
// with a shared passphrase is mandatory on both ends.
|
|
75
|
+
export const vaultTransferExportInputSchema = {
|
|
76
|
+
...vaultInput,
|
|
77
|
+
passphrase: z
|
|
78
|
+
.string()
|
|
79
|
+
.min(8)
|
|
80
|
+
.describe('Shared passphrase (min 8 chars) used to encrypt the transfer payload. The receiving Brainlink must import with the same passphrase.')
|
|
81
|
+
};
|
|
82
|
+
export const vaultTransferImportInputSchema = {
|
|
83
|
+
...vaultInput,
|
|
84
|
+
payloadBase64: z
|
|
85
|
+
.string()
|
|
86
|
+
.min(1)
|
|
87
|
+
.describe('Base64 transfer payload produced by brainlink_vault_transfer_export on the source Brainlink.'),
|
|
88
|
+
passphrase: z.string().min(8).describe('The same passphrase used on the source Brainlink to export the payload.'),
|
|
54
89
|
skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
|
|
55
90
|
};
|
|
56
91
|
export const vaultProvisionInputSchema = {
|
|
@@ -156,7 +191,11 @@ export const vaultRestoreTool = async (input) => {
|
|
|
156
191
|
export const vaultExportTool = async (input) => {
|
|
157
192
|
const context = await resolveExecutionContext(input);
|
|
158
193
|
try {
|
|
159
|
-
return jsonResult({
|
|
194
|
+
return jsonResult({
|
|
195
|
+
vault: context.vault,
|
|
196
|
+
ok: true,
|
|
197
|
+
...(await exportVaultSnapshot(context.vault, input.outputFile, { passphrase: input.passphrase, encrypt: input.encrypt }))
|
|
198
|
+
});
|
|
160
199
|
}
|
|
161
200
|
catch (error) {
|
|
162
201
|
return failure(context.vault, error);
|
|
@@ -165,7 +204,10 @@ export const vaultExportTool = async (input) => {
|
|
|
165
204
|
export const vaultImportTool = async (input) => {
|
|
166
205
|
const context = await resolveExecutionContext(input);
|
|
167
206
|
try {
|
|
168
|
-
const result = await importVaultSnapshot(input.snapshotFile, context.vault, {
|
|
207
|
+
const result = await importVaultSnapshot(input.snapshotFile, context.vault, {
|
|
208
|
+
skipIndex: input.skipIndex === true,
|
|
209
|
+
passphrase: input.passphrase
|
|
210
|
+
});
|
|
169
211
|
return jsonResult({
|
|
170
212
|
vault: context.vault,
|
|
171
213
|
ok: true,
|
|
@@ -182,7 +224,10 @@ export const vaultExportBundleTool = async (input) => {
|
|
|
182
224
|
const context = await resolveExecutionContext(input);
|
|
183
225
|
try {
|
|
184
226
|
const config = await loadBrainlinkConfig();
|
|
185
|
-
const result = await exportVaultsBundle(getKnownVaults(config), input.outputFile
|
|
227
|
+
const result = await exportVaultsBundle(getKnownVaults(config), input.outputFile, {
|
|
228
|
+
passphrase: input.passphrase,
|
|
229
|
+
encrypt: input.encrypt
|
|
230
|
+
});
|
|
186
231
|
return jsonResult({ ok: true, ...result });
|
|
187
232
|
}
|
|
188
233
|
catch (error) {
|
|
@@ -191,7 +236,7 @@ export const vaultExportBundleTool = async (input) => {
|
|
|
191
236
|
};
|
|
192
237
|
export const vaultBundleListTool = async (input) => {
|
|
193
238
|
try {
|
|
194
|
-
return jsonResult({ ok: true, bundleFile: input.bundleFile, vaults: await listBundleVaults(input.bundleFile) });
|
|
239
|
+
return jsonResult({ ok: true, bundleFile: input.bundleFile, vaults: await listBundleVaults(input.bundleFile, input.passphrase) });
|
|
195
240
|
}
|
|
196
241
|
catch (error) {
|
|
197
242
|
return jsonResult({ ok: false, bundleFile: input.bundleFile, error: error instanceof Error ? error.message : String(error) });
|
|
@@ -202,7 +247,8 @@ export const vaultImportBundleTool = async (input) => {
|
|
|
202
247
|
const result = await importVaultsBundle(input.bundleFile, {
|
|
203
248
|
only: input.only,
|
|
204
249
|
targetVault: input.targetVault,
|
|
205
|
-
skipIndex: input.skipIndex === true
|
|
250
|
+
skipIndex: input.skipIndex === true,
|
|
251
|
+
passphrase: input.passphrase
|
|
206
252
|
});
|
|
207
253
|
return jsonResult({ ok: true, ...result });
|
|
208
254
|
}
|
|
@@ -210,6 +256,43 @@ export const vaultImportBundleTool = async (input) => {
|
|
|
210
256
|
return jsonResult({ ok: false, bundleFile: input.bundleFile, error: error instanceof Error ? error.message : String(error) });
|
|
211
257
|
}
|
|
212
258
|
};
|
|
259
|
+
export const vaultTransferExportTool = async (input) => {
|
|
260
|
+
const context = await resolveExecutionContext(input);
|
|
261
|
+
try {
|
|
262
|
+
const artifact = await packVaultSnapshot(context.vault, { passphrase: input.passphrase, requireEncryption: true });
|
|
263
|
+
return jsonResult({
|
|
264
|
+
vault: context.vault,
|
|
265
|
+
ok: true,
|
|
266
|
+
encrypted: artifact.encrypted,
|
|
267
|
+
fileCount: artifact.fileCount,
|
|
268
|
+
bytes: artifact.payload.byteLength,
|
|
269
|
+
payloadBase64: artifact.payload.toString('base64')
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
return failure(context.vault, error);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
export const vaultTransferImportTool = async (input) => {
|
|
277
|
+
const context = await resolveExecutionContext(input);
|
|
278
|
+
try {
|
|
279
|
+
const payload = Buffer.from(input.payloadBase64, 'base64');
|
|
280
|
+
const result = await applyVaultSnapshotPayload(payload, context.vault, {
|
|
281
|
+
skipIndex: input.skipIndex === true,
|
|
282
|
+
passphrase: input.passphrase
|
|
283
|
+
});
|
|
284
|
+
return jsonResult({
|
|
285
|
+
vault: context.vault,
|
|
286
|
+
ok: true,
|
|
287
|
+
imported: result.imported,
|
|
288
|
+
conflicted: result.conflicted,
|
|
289
|
+
reindexed: result.index != null
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return failure(context.vault, error);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
213
296
|
export const vaultMergeAgentsTool = async (input) => {
|
|
214
297
|
const context = await resolveExecutionContext(input);
|
|
215
298
|
try {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { brokenLinksInputSchema, brokenLinksTool, contextInputSchema, contextPacksInputSchema, contextPacksTool, contextTool, doctorActionsInputSchema, doctorActionsTool, explainInputSchema, explainTool, graphContextsInputSchema, graphContextsTool, graphInputSchema, graphTool, getImageInputSchema, getImageTool, orphansInputSchema, orphansTool, recommendationsInputSchema, recommendationsTool, searchInputSchema, searchTool, similarNotesInputSchema, similarNotesTool, statsInputSchema, statsTool, suggestLinksInputSchema, suggestLinksTool, validateInputSchema, validateTool, versionInputSchema, versionTool } from './tools/read-tools.js';
|
|
2
2
|
export { addFileInputSchema, addFileTool, addImageInputSchema, addImageTool, addNoteInputSchema, addNoteTool, addNotesInputSchema, addNotesTool, deleteNoteInputSchema, deleteNoteTool, deleteNotesInputSchema, deleteNotesTool, inboxAddInputSchema, inboxAddTool, inboxListInputSchema, inboxListTool, inboxProcessInputSchema, inboxProcessTool, rememberInputSchema, rememberTool, volatileAddInputSchema, volatileAddTool, volatileClearInputSchema, volatileClearTool } from './tools/write-tools.js';
|
|
3
3
|
export { bootstrapInputSchema, bootstrapTool, canonicalizeContextLinksInputSchema, canonicalizeContextLinksTool, dedupeInputSchema, dedupeResolveInputSchema, dedupeResolveTool, dedupeTool, indexInputSchema, indexTool, policyInputSchema, policyTool, projectInitInputSchema, projectInitTool, repairLinksInputSchema, repairLinksTool, sessionCloseInputSchema, sessionCloseTool, syncInputSchema, syncTool } from './tools/maintenance-tools.js';
|
|
4
|
-
export { vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool } from './tools/vault-tools.js';
|
|
4
|
+
export { vaultBundleListInputSchema, vaultBundleListTool, vaultCommitInputSchema, vaultCommitTool, vaultExportBundleInputSchema, vaultExportBundleTool, vaultExportInputSchema, vaultExportTool, vaultHistoryInputSchema, vaultHistoryTool, vaultImportBundleInputSchema, vaultImportBundleTool, vaultImportInputSchema, vaultImportTool, vaultInitInputSchema, vaultInitTool, vaultMergeAgentsInputSchema, vaultMergeAgentsTool, vaultProvisionInputSchema, vaultProvisionTool, vaultPullInputSchema, vaultPullTool, vaultRestoreInputSchema, vaultRestoreTool, vaultStatusInputSchema, vaultStatusTool, vaultTransferExportInputSchema, vaultTransferExportTool, vaultTransferImportInputSchema, vaultTransferImportTool } from './tools/vault-tools.js';
|
package/package.json
CHANGED