@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 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>/`. Common secret patterns are blocked by default; use `--allow-sensitive` only for an intentionally protected vault.
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
- blink vault-export -o vault.blvault
981
- blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
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
- - Brainlink blocks common secret patterns by default when adding notes. Use `--allow-sensitive` only for intentional, protected vaults.
1480
- - Do not store secrets, credentials, API keys or regulated personal data unless the vault is protected by your own storage controls.
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(createJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
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 ?? createJsonResponse({ signature, ...encodeLayoutPayload(layout) });
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(createJsonResponse(await getGraphView(vaultPath, {
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(createJsonResponse(await getGraphStreamChunk(vaultPath, {
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(createJsonResponse({ node }), 200, contentTypes['.json']);
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(createJsonResponse({ query, agent: readAgentQuery(url), limit, mode, results: await searchKnowledge(vaultPath, query, limit, readAgentQuery(url), mode) }), 200, contentTypes['.json']);
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(createJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs, cagPackTtlMs)), 200, contentTypes['.json']);
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
- const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
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 packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
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
- import { createHash } from 'node:crypto';
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
- export const exportVaultSnapshot = async (vaultPath, outputFile) => {
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 packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
43
- await writeFile(outputFile, packed, { mode: 0o600 });
44
- return { file: outputFile, fileCount: envelope.files.length, bytes: packed.byteLength };
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
- const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
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
  };
@@ -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 sensitivePatterns = [
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) => sensitivePatterns
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
- await mkdir(contextPacksDirectory(vaultPath), { recursive: true });
159
- await writeFile(path, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
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).
@@ -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({ vault: context.vault, ok: true, ...(await exportVaultSnapshot(context.vault, input.outputFile)) });
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, { skipIndex: input.skipIndex === true });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.8.2",
3
+ "version": "1.10.0",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",