@andespindola/brainlink 1.8.1 → 1.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.2
4
+
5
+ - **Connect page AGENTS.md now instructs proactive capture.** The ready operating contract on the `/connect` page changed its "After you learn" guidance from only writing back "durable learnings" to capturing proactively — decisions and their rationale, project maps/paths, conventions, runbooks, recent state, and recurring pitfalls; when in doubt, capture (using volatile memory only for the truly ephemeral), then reindex. So agents wired to a brainlink persist everything worth reusing, not just occasional highlights.
6
+
3
7
  ## 1.8.1
4
8
 
5
9
  - **Fix the Connect page's Codex snippet auth.** The `/connect` page's Codex `config.toml` block passed the bearer token via a `MCP_AUTHORIZATION` env var, but `mcp-remote` does not read that env — so the request went out unauthenticated and `mcp-remote` fell back to a hanging OAuth flow. The token is now passed as an `mcp-remote` `--header "Authorization: Bearer <token>"` argument (verified to connect), and both the remote and local Codex snippets gained `startup_timeout_sec = 60` so Codex doesn't time out the cold-start MCP handshake.
package/README.md CHANGED
@@ -562,8 +562,8 @@ 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
567
  - `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
568
  - `brainlink_graph`: read indexed graph nodes and weighted links.
569
569
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
@@ -884,7 +884,7 @@ blink add "Note Title" --vault ./vault --content-file ./notes.md --no-auto-index
884
884
 
885
885
  `--content` and `--content-file` are mutually exclusive. Add `--no-auto-index` when you want to defer reindexing.
886
886
 
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.
887
+ 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
888
  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
889
  `add` also returns `possibleDuplicates` (exact hash + semantic candidates) so agents can resolve duplicate memory right after writes.
890
890
 
@@ -977,13 +977,15 @@ blink vault-clone git@github.com:you/your-vault.git ./vault # clone + build in
977
977
  blink vault-status
978
978
 
979
979
  # 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
980
+ # Encrypted (AES-256-GCM) with a passphrase; without one it warns and writes plaintext gzip.
981
+ blink vault-export --passphrase "$PHRASE" -o vault.blvault
982
+ blink vault-import vault.blvault --passphrase "$PHRASE" # conflicting local edits preserved as *.conflict-<ts>.md
983
+ blink vault-export --no-encrypt -o vault.blvault # force legacy plaintext gzip
982
984
 
983
985
  # 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
986
+ blink vault-export --all --passphrase "$PHRASE" -o vaults.blbundle
987
+ blink vault-import vaults.blbundle --passphrase "$PHRASE" # restore all vaults to their recorded paths
988
+ blink vault-import vaults.blbundle --only shared --vault ./restored --passphrase "$PHRASE" # restore just one
987
989
  ```
988
990
 
989
991
  Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
@@ -1476,8 +1478,9 @@ Brainlink is local-first by default.
1476
1478
 
1477
1479
  - Do not expose the HTTP server publicly without authentication.
1478
1480
  - 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.
1481
+ - 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.
1482
+ - 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.
1483
+ - 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
1484
  - Treat `.brainlink/index.json` and `.brainlink/search-packs/` as disposable derived artifacts.
1482
1485
 
1483
1486
  See [SECURITY.md](SECURITY.md).
@@ -41,10 +41,15 @@ it as the continuity of your cognition, not an optional lookup.
41
41
  - Retrieve with \`strategy: "auto"\` (CAG on a fresh pack, RAG otherwise) and
42
42
  \`mode: "hybrid"\` (FTS + semantic) unless a narrower mode clearly fits.
43
43
 
44
- ## After you learn
45
- - Write durable learnings back with \`brainlink_remember\` (or
46
- \`brainlink_add_note\` for structured notes), then reindex with
47
- \`brainlink_index\`.
44
+ ## Capture proactively — save everything worth reusing
45
+ - Do not wait for an explicit "durable learning". After anything worth reusing,
46
+ write it back: decisions and their rationale, project maps and paths,
47
+ conventions, runbooks, recent state (last actions / open work), and recurring
48
+ pitfalls plus how you worked around them.
49
+ - When in doubt, capture — the cost is low and reuse is likely. Use volatile
50
+ memory only for the truly ephemeral.
51
+ - Persist with \`brainlink_remember\` (or \`brainlink_add_note\` for structured
52
+ notes), then reindex with \`brainlink_index\`.
48
53
  - Write connected memory: include \`[[wiki links]]\`, tags, and priority markers
49
54
  so notes join the knowledge graph instead of sitting orphaned.
50
55
  `;
@@ -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,32 @@ 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
+ export const exportVaultSnapshot = async (vaultPath, outputFile, options = {}) => {
41
53
  const envelope = await buildSnapshotEnvelope(vaultPath);
42
- const packed = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
54
+ const gzipped = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
55
+ const key = options.encrypt !== false ? resolveSnapshotKey(options.passphrase) : null;
56
+ if (options.encrypt !== false && !key) {
57
+ process.stderr.write('brainlink: snapshot exported unencrypted — pass --passphrase (or set BRAINLINK_VAULT_KEY) to encrypt a portable snapshot.\n');
58
+ }
59
+ const packed = key ? encryptVaultContent(key, gzipped) : gzipped;
43
60
  await writeFile(outputFile, packed, { mode: 0o600 });
44
- return { file: outputFile, fileCount: envelope.files.length, bytes: packed.byteLength };
61
+ return { file: outputFile, fileCount: envelope.files.length, bytes: packed.byteLength, encrypted: Boolean(key) };
45
62
  };
46
- const parseEnvelope = (raw) => {
47
- const envelope = JSON.parse(gunzipSync(raw).toString('utf8'));
63
+ const parseEnvelope = (raw, passphrase) => {
64
+ let gzipped = raw;
65
+ if (isEncryptedVaultPayload(raw)) {
66
+ const key = resolveSnapshotKey(passphrase);
67
+ if (!key) {
68
+ throw new Error('Encrypted .blvault requires --passphrase (or BRAINLINK_VAULT_KEY).');
69
+ }
70
+ try {
71
+ gzipped = decryptVaultContent(key, raw);
72
+ }
73
+ catch {
74
+ throw new Error('Encrypted .blvault could not be decrypted. Check the --passphrase / BRAINLINK_VAULT_KEY value.');
75
+ }
76
+ }
77
+ const envelope = JSON.parse(gunzipSync(gzipped).toString('utf8'));
48
78
  if (!envelope || envelope.version !== snapshotVersion || !Array.isArray(envelope.files)) {
49
79
  throw new Error('Unrecognized .blvault snapshot format.');
50
80
  }
@@ -88,6 +118,6 @@ export const applySnapshotEnvelope = async (envelope, vaultPath, options = {}) =
88
118
  return { imported, conflicted, index };
89
119
  };
90
120
  export const importVaultSnapshot = async (snapshotFile, vaultPath, options = {}) => {
91
- const envelope = parseEnvelope(await readFile(snapshotFile));
121
+ const envelope = parseEnvelope(await readFile(snapshotFile), options.passphrase);
92
122
  return applySnapshotEnvelope(envelope, vaultPath, options);
93
123
  };
@@ -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).
@@ -33,24 +33,39 @@ 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.'),
54
69
  skipIndex: z.boolean().optional().default(false).describe('Skip the automatic reindex after importing Markdown.')
55
70
  };
56
71
  export const vaultProvisionInputSchema = {
@@ -156,7 +171,11 @@ export const vaultRestoreTool = async (input) => {
156
171
  export const vaultExportTool = async (input) => {
157
172
  const context = await resolveExecutionContext(input);
158
173
  try {
159
- return jsonResult({ vault: context.vault, ok: true, ...(await exportVaultSnapshot(context.vault, input.outputFile)) });
174
+ return jsonResult({
175
+ vault: context.vault,
176
+ ok: true,
177
+ ...(await exportVaultSnapshot(context.vault, input.outputFile, { passphrase: input.passphrase, encrypt: input.encrypt }))
178
+ });
160
179
  }
161
180
  catch (error) {
162
181
  return failure(context.vault, error);
@@ -165,7 +184,10 @@ export const vaultExportTool = async (input) => {
165
184
  export const vaultImportTool = async (input) => {
166
185
  const context = await resolveExecutionContext(input);
167
186
  try {
168
- const result = await importVaultSnapshot(input.snapshotFile, context.vault, { skipIndex: input.skipIndex === true });
187
+ const result = await importVaultSnapshot(input.snapshotFile, context.vault, {
188
+ skipIndex: input.skipIndex === true,
189
+ passphrase: input.passphrase
190
+ });
169
191
  return jsonResult({
170
192
  vault: context.vault,
171
193
  ok: true,
@@ -182,7 +204,10 @@ export const vaultExportBundleTool = async (input) => {
182
204
  const context = await resolveExecutionContext(input);
183
205
  try {
184
206
  const config = await loadBrainlinkConfig();
185
- const result = await exportVaultsBundle(getKnownVaults(config), input.outputFile);
207
+ const result = await exportVaultsBundle(getKnownVaults(config), input.outputFile, {
208
+ passphrase: input.passphrase,
209
+ encrypt: input.encrypt
210
+ });
186
211
  return jsonResult({ ok: true, ...result });
187
212
  }
188
213
  catch (error) {
@@ -191,7 +216,7 @@ export const vaultExportBundleTool = async (input) => {
191
216
  };
192
217
  export const vaultBundleListTool = async (input) => {
193
218
  try {
194
- return jsonResult({ ok: true, bundleFile: input.bundleFile, vaults: await listBundleVaults(input.bundleFile) });
219
+ return jsonResult({ ok: true, bundleFile: input.bundleFile, vaults: await listBundleVaults(input.bundleFile, input.passphrase) });
195
220
  }
196
221
  catch (error) {
197
222
  return jsonResult({ ok: false, bundleFile: input.bundleFile, error: error instanceof Error ? error.message : String(error) });
@@ -202,7 +227,8 @@ export const vaultImportBundleTool = async (input) => {
202
227
  const result = await importVaultsBundle(input.bundleFile, {
203
228
  only: input.only,
204
229
  targetVault: input.targetVault,
205
- skipIndex: input.skipIndex === true
230
+ skipIndex: input.skipIndex === true,
231
+ passphrase: input.passphrase
206
232
  });
207
233
  return jsonResult({ ok: true, ...result });
208
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",