@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 +4 -0
- package/README.md +13 -10
- package/dist/application/frontend/connect-page.js +9 -4
- package/dist/application/server/http.js +5 -0
- package/dist/application/server/routes.js +8 -8
- package/dist/application/vault-bundle.js +37 -8
- package/dist/application/vault-snapshot.js +38 -8
- package/dist/cli/commands/vault-sync-commands.js +13 -8
- package/dist/cli/runtime.js +6 -1
- package/dist/domain/note-safety.js +41 -14
- package/dist/infrastructure/context-packs.js +5 -2
- package/dist/infrastructure/vault-crypto.js +4 -0
- package/dist/mcp/tools/vault-tools.js +34 -8
- package/package.json +1 -1
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>/`.
|
|
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
|
-
|
|
981
|
-
blink vault-
|
|
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
|
-
-
|
|
1480
|
-
-
|
|
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
|
-
##
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
426
|
+
return createResponse(createMaskedJsonResponse(await getGraph(vaultPath, readAgentQuery(url))), 200, contentTypes['.json']);
|
|
427
427
|
}
|
|
428
428
|
if (isReadMethod(request) && url.pathname === '/api/graph-layout') {
|
|
429
429
|
const { signature, layout } = await getGraphLayout(vaultPath, {
|
|
@@ -444,7 +444,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
446
|
const cachedBody = readGraphLayoutBody(signature);
|
|
447
|
-
const body = cachedBody ??
|
|
447
|
+
const body = cachedBody ?? createMaskedJsonResponse({ signature, ...encodeLayoutPayload(layout) });
|
|
448
448
|
if (!cachedBody) {
|
|
449
449
|
storeGraphLayoutBody(signature, body);
|
|
450
450
|
}
|
|
@@ -458,7 +458,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
458
458
|
};
|
|
459
459
|
}
|
|
460
460
|
if (isReadMethod(request) && url.pathname === '/api/graph-view') {
|
|
461
|
-
return createResponse(
|
|
461
|
+
return createResponse(createMaskedJsonResponse(await getGraphView(vaultPath, {
|
|
462
462
|
x: parseNumber(url.searchParams.get('x'), -1000),
|
|
463
463
|
y: parseNumber(url.searchParams.get('y'), -1000),
|
|
464
464
|
width: parseNumber(url.searchParams.get('w'), 2000),
|
|
@@ -476,7 +476,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
476
476
|
const scale = parseNumber(url.searchParams.get('scale'), 0.24);
|
|
477
477
|
const nodeBudget = parsePositiveInteger(url.searchParams.get('nodeBudget'), 1800);
|
|
478
478
|
const edgeBudget = parsePositiveInteger(url.searchParams.get('edgeBudget'), 5000);
|
|
479
|
-
return createResponse(
|
|
479
|
+
return createResponse(createMaskedJsonResponse(await getGraphStreamChunk(vaultPath, {
|
|
480
480
|
x,
|
|
481
481
|
y,
|
|
482
482
|
width,
|
|
@@ -554,7 +554,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
554
554
|
if (!node) {
|
|
555
555
|
return createResponse(createJsonResponse({ error: 'Node not found' }), 404, contentTypes['.json']);
|
|
556
556
|
}
|
|
557
|
-
return createResponse(
|
|
557
|
+
return createResponse(createMaskedJsonResponse({ node }), 200, contentTypes['.json']);
|
|
558
558
|
}
|
|
559
559
|
if (isReadMethod(request) && url.pathname === '/api/graph-filter') {
|
|
560
560
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
|
@@ -625,7 +625,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
625
625
|
if (hasInvalidSearchMode(url)) {
|
|
626
626
|
return createResponse(createJsonResponse({ error: 'Invalid mode. Use fts, semantic or hybrid.' }), 400, contentTypes['.json']);
|
|
627
627
|
}
|
|
628
|
-
return createResponse(
|
|
628
|
+
return createResponse(createMaskedJsonResponse({ query, agent: readAgentQuery(url), limit, mode, results: await searchKnowledge(vaultPath, query, limit, readAgentQuery(url), mode) }), 200, contentTypes['.json']);
|
|
629
629
|
}
|
|
630
630
|
if (isReadMethod(request) && url.pathname === '/api/context') {
|
|
631
631
|
const query = url.searchParams.get('q') ?? '';
|
|
@@ -642,7 +642,7 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
642
642
|
if (hasInvalidContextStrategy(url)) {
|
|
643
643
|
return createResponse(createJsonResponse({ error: 'Invalid strategy. Use rag, cag or auto.' }), 400, contentTypes['.json']);
|
|
644
644
|
}
|
|
645
|
-
return createResponse(
|
|
645
|
+
return createResponse(createMaskedJsonResponse(await buildContextPackage(vaultPath, query, limit, tokens, readAgentQuery(url), mode, strategy, contextCacheTtlMs, cagPackTtlMs)), 200, contentTypes['.json']);
|
|
646
646
|
}
|
|
647
647
|
if (isReadMethod(request) && url.pathname === '/api/links') {
|
|
648
648
|
return createResponse(createJsonResponse({ links: await listLinks(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
// snapshot per vault, built on the reusable single-vault snapshot helpers. Exports
|
|
3
3
|
// all known vaults (or a chosen subset) into one movable file; import restores every
|
|
4
4
|
// bundled vault back to its recorded path or a chosen target, preserving conflicts.
|
|
5
|
+
// Because a bundle spans multiple vaults it has no single per-vault key, so it is
|
|
6
|
+
// encrypted (AES-256-GCM) whenever a passphrase (or BRAINLINK_VAULT_KEY) is
|
|
7
|
+
// available; without one it falls back to legacy plaintext gzip with a warning.
|
|
8
|
+
// Legacy plaintext bundles still import (auto-detected).
|
|
5
9
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
6
10
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
7
11
|
import { basename, resolve } from 'node:path';
|
|
8
12
|
import { buildSnapshotEnvelope, applySnapshotEnvelope } from './vault-snapshot.js';
|
|
13
|
+
import { decryptVaultContent, deriveVaultKeyFromPassphrase, encryptVaultContent, isEncryptedVaultPayload } from '../infrastructure/vault-crypto.js';
|
|
9
14
|
const bundleVersion = 1;
|
|
10
15
|
const bundleKind = 'brainlink-vault-bundle';
|
|
16
|
+
// A bundle key comes only from a passphrase or BRAINLINK_VAULT_KEY (no per-vault
|
|
17
|
+
// keyfile, since a bundle spans vaults). Returns null when neither is available.
|
|
18
|
+
const resolveBundleKey = (passphrase) => {
|
|
19
|
+
const secret = (passphrase && passphrase.length > 0 ? passphrase : process.env.BRAINLINK_VAULT_KEY?.trim()) ?? '';
|
|
20
|
+
return secret.length > 0 ? deriveVaultKeyFromPassphrase(secret) : null;
|
|
21
|
+
};
|
|
11
22
|
const dedupePaths = (vaultPaths) => {
|
|
12
23
|
const seen = new Set();
|
|
13
24
|
const paths = [];
|
|
@@ -20,14 +31,27 @@ const dedupePaths = (vaultPaths) => {
|
|
|
20
31
|
}
|
|
21
32
|
return paths;
|
|
22
33
|
};
|
|
23
|
-
const parseBundle = (raw) => {
|
|
24
|
-
|
|
34
|
+
const parseBundle = (raw, passphrase) => {
|
|
35
|
+
let gzipped = raw;
|
|
36
|
+
if (isEncryptedVaultPayload(raw)) {
|
|
37
|
+
const key = resolveBundleKey(passphrase);
|
|
38
|
+
if (!key) {
|
|
39
|
+
throw new Error('Encrypted .blbundle requires --passphrase (or BRAINLINK_VAULT_KEY).');
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
gzipped = decryptVaultContent(key, raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error('Encrypted .blbundle could not be decrypted. Check the --passphrase / BRAINLINK_VAULT_KEY value.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const envelope = JSON.parse(gunzipSync(gzipped).toString('utf8'));
|
|
25
49
|
if (!envelope || envelope.kind !== bundleKind || envelope.version !== bundleVersion || !Array.isArray(envelope.vaults)) {
|
|
26
50
|
throw new Error('Unrecognized .blbundle format.');
|
|
27
51
|
}
|
|
28
52
|
return envelope;
|
|
29
53
|
};
|
|
30
|
-
export const exportVaultsBundle = async (vaultPaths, outputFile) => {
|
|
54
|
+
export const exportVaultsBundle = async (vaultPaths, outputFile, options = {}) => {
|
|
31
55
|
const uniquePaths = dedupePaths(vaultPaths);
|
|
32
56
|
const vaults = await Promise.all(uniquePaths.map(async (vaultPath) => {
|
|
33
57
|
const vault = resolve(vaultPath);
|
|
@@ -39,13 +63,18 @@ export const exportVaultsBundle = async (vaultPaths, outputFile) => {
|
|
|
39
63
|
createdAt: new Date().toISOString(),
|
|
40
64
|
vaults
|
|
41
65
|
};
|
|
42
|
-
const
|
|
66
|
+
const gzipped = gzipSync(Buffer.from(JSON.stringify(envelope), 'utf8'), { level: 9 });
|
|
67
|
+
const key = options.encrypt !== false ? resolveBundleKey(options.passphrase) : null;
|
|
68
|
+
if (options.encrypt !== false && !key) {
|
|
69
|
+
process.stderr.write('brainlink: bundle exported unencrypted — pass --passphrase (or set BRAINLINK_VAULT_KEY) to encrypt a portable bundle.\n');
|
|
70
|
+
}
|
|
71
|
+
const packed = key ? encryptVaultContent(key, gzipped) : gzipped;
|
|
43
72
|
await writeFile(outputFile, packed, { mode: 0o600 });
|
|
44
73
|
const fileCount = vaults.reduce((total, entry) => total + entry.snapshot.files.length, 0);
|
|
45
|
-
return { file: outputFile, vaultCount: vaults.length, fileCount, bytes: packed.byteLength };
|
|
74
|
+
return { file: outputFile, vaultCount: vaults.length, fileCount, bytes: packed.byteLength, encrypted: Boolean(key) };
|
|
46
75
|
};
|
|
47
|
-
export const listBundleVaults = async (bundleFile) => {
|
|
48
|
-
const envelope = parseBundle(await readFile(bundleFile));
|
|
76
|
+
export const listBundleVaults = async (bundleFile, passphrase) => {
|
|
77
|
+
const envelope = parseBundle(await readFile(bundleFile), passphrase);
|
|
49
78
|
return envelope.vaults.map((entry) => ({
|
|
50
79
|
name: entry.name,
|
|
51
80
|
vault: entry.vault,
|
|
@@ -53,7 +82,7 @@ export const listBundleVaults = async (bundleFile) => {
|
|
|
53
82
|
}));
|
|
54
83
|
};
|
|
55
84
|
export const importVaultsBundle = async (bundleFile, options = {}) => {
|
|
56
|
-
const envelope = parseBundle(await readFile(bundleFile));
|
|
85
|
+
const envelope = parseBundle(await readFile(bundleFile), options.passphrase);
|
|
57
86
|
const selected = options.only
|
|
58
87
|
? envelope.vaults.filter((entry) => entry.name === options.only || entry.vault === options.only)
|
|
59
88
|
: envelope.vaults;
|
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
// canonical Markdown (with per-file SHA-256), self-contained and movable to any
|
|
3
3
|
// machine. The derived index is never included — import rebuilds it so the vault
|
|
4
4
|
// is immediately usable. Conflicts on import are preserved as `*.conflict-<ts>.md`
|
|
5
|
-
// rather than overwriting local edits.
|
|
6
|
-
|
|
5
|
+
// rather than overwriting local edits. A portable artifact can only be encrypted
|
|
6
|
+
// with a shared secret, so the snapshot is AES-256-GCM encrypted whenever a
|
|
7
|
+
// passphrase (or BRAINLINK_VAULT_KEY) is available and falls back to legacy
|
|
8
|
+
// plaintext gzip (with a warning) otherwise. Legacy gzip snapshots still import
|
|
9
|
+
// (auto-detected); `--no-encrypt` forces plaintext even when a key is present.
|
|
7
10
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
8
11
|
import { extname, relative } from 'node:path';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
9
13
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
14
|
import { indexVault } from './index-vault.js';
|
|
11
15
|
import { ensureVault, listVaultFiles, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
|
|
16
|
+
import { decryptVaultContent, deriveVaultKeyFromPassphrase, encryptVaultContent, isEncryptedVaultPayload } from '../infrastructure/vault-crypto.js';
|
|
12
17
|
const snapshotVersion = 1;
|
|
18
|
+
// A snapshot key comes only from a passphrase or BRAINLINK_VAULT_KEY (never a
|
|
19
|
+
// per-vault keyfile, which would make the "portable" artifact undecryptable on
|
|
20
|
+
// another machine/path). Returns null when neither is available.
|
|
21
|
+
const resolveSnapshotKey = (passphrase) => {
|
|
22
|
+
const secret = (passphrase && passphrase.length > 0 ? passphrase : process.env.BRAINLINK_VAULT_KEY?.trim()) ?? '';
|
|
23
|
+
return secret.length > 0 ? deriveVaultKeyFromPassphrase(secret) : null;
|
|
24
|
+
};
|
|
13
25
|
const isMarkdown = (path) => extname(path).toLowerCase() === '.md';
|
|
14
26
|
const toPosix = (path) => path.split('\\').join('/');
|
|
15
27
|
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
@@ -37,14 +49,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
|
|
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
|
-
|
|
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
|
};
|
package/dist/cli/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { autoMigrateConfiguredVaultIfChanged } from '../application/auto-migrate-configured-vault.js';
|
|
2
|
+
import { maskSensitiveDeep, maskSensitiveText } from '../domain/note-safety.js';
|
|
2
3
|
import { loadBrainlinkConfigWithSource, resolveAgentRuntimeDefaults } from '../infrastructure/config.js';
|
|
3
4
|
import { assertVaultAllowed } from '../infrastructure/file-system-vault.js';
|
|
4
5
|
export const parsePositiveInteger = (value, fallback) => {
|
|
@@ -25,6 +26,10 @@ export const resolveOptions = async (options) => {
|
|
|
25
26
|
defaults
|
|
26
27
|
};
|
|
27
28
|
};
|
|
29
|
+
// The CLI is a user-facing surface, so any secret stored in a note is masked
|
|
30
|
+
// here before it reaches the terminal — both for `--json` (deep-masked object)
|
|
31
|
+
// and human-readable output (masked rendered string). MCP tool responses go
|
|
32
|
+
// through a separate path (jsonResult) and stay unmasked for agents.
|
|
28
33
|
export const print = (json, value, human) => {
|
|
29
|
-
console.log(json ? JSON.stringify(value, null, 2) : human());
|
|
34
|
+
console.log(json ? JSON.stringify(maskSensitiveDeep(value), null, 2) : maskSensitiveText(human()));
|
|
30
35
|
};
|
|
@@ -1,38 +1,70 @@
|
|
|
1
1
|
const maxTitleLength = 160;
|
|
2
2
|
const maxContentLength = 200_000;
|
|
3
|
-
const
|
|
3
|
+
const mask = '••••••••';
|
|
4
|
+
const sensitiveRules = [
|
|
4
5
|
{
|
|
5
6
|
label: 'private key',
|
|
6
|
-
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY
|
|
7
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
8
|
+
mask: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
9
|
+
replace: () => '[redacted: private key]'
|
|
7
10
|
},
|
|
8
11
|
{
|
|
9
12
|
label: 'OpenAI API key',
|
|
10
|
-
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b
|
|
13
|
+
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
14
|
+
mask: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
15
|
+
replace: () => `sk-${mask}`
|
|
11
16
|
},
|
|
12
17
|
{
|
|
13
18
|
label: 'GitHub token',
|
|
14
|
-
pattern: /\b(?:ghp|gho|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b
|
|
19
|
+
pattern: /\b(?:ghp|gho|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/,
|
|
20
|
+
mask: /\b((?:ghp|gho|ghs|ghr|github_pat)_)[A-Za-z0-9_]{20,}\b/g,
|
|
21
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
15
22
|
},
|
|
16
23
|
{
|
|
17
24
|
label: 'AWS access key',
|
|
18
|
-
pattern: /\bAKIA[0-9A-Z]{16}\b
|
|
25
|
+
pattern: /\bAKIA[0-9A-Z]{16}\b/,
|
|
26
|
+
mask: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
27
|
+
replace: () => `AKIA${mask}`
|
|
19
28
|
},
|
|
20
29
|
{
|
|
21
30
|
label: 'Slack token',
|
|
22
|
-
pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b
|
|
31
|
+
pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}\b/,
|
|
32
|
+
mask: /\b(xox[abprs]-)[A-Za-z0-9-]{20,}\b/g,
|
|
33
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
23
34
|
},
|
|
24
35
|
{
|
|
25
36
|
label: 'JWT',
|
|
26
|
-
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b
|
|
37
|
+
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
38
|
+
mask: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
39
|
+
replace: () => '[redacted: JWT]'
|
|
27
40
|
},
|
|
28
41
|
{
|
|
29
42
|
label: 'secret assignment',
|
|
30
|
-
pattern: /\b(?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i
|
|
43
|
+
pattern: /\b(?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i,
|
|
44
|
+
mask: /\b((?:api[_-]?key|secret|password|private[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*["']?)[A-Za-z0-9_./+=-]{12,}/gi,
|
|
45
|
+
replace: (_full, prefix) => `${prefix}${mask}`
|
|
31
46
|
}
|
|
32
47
|
];
|
|
33
|
-
export const findSensitiveContent = (value) =>
|
|
48
|
+
export const findSensitiveContent = (value) => sensitiveRules
|
|
34
49
|
.filter(({ pattern }) => pattern.test(value))
|
|
35
50
|
.map(({ label }) => ({ label }));
|
|
51
|
+
// Replace every recognized secret in a string with a masked marker, preserving
|
|
52
|
+
// surrounding text. Used only at user-facing surfaces (CLI, graph web UI).
|
|
53
|
+
export const maskSensitiveText = (value) => sensitiveRules.reduce((masked, rule) => masked.replace(rule.mask, rule.replace), value);
|
|
54
|
+
// Deep-mask every string in an arbitrary JSON-serializable value. Returns a new
|
|
55
|
+
// structure; the original (used to answer MCP agents) is never mutated.
|
|
56
|
+
export const maskSensitiveDeep = (value) => {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
return maskSensitiveText(value);
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
return value.map((item) => maskSensitiveDeep(item));
|
|
62
|
+
}
|
|
63
|
+
if (value !== null && typeof value === 'object') {
|
|
64
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskSensitiveDeep(item)]));
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
};
|
|
36
68
|
export const validateNoteInput = (input) => {
|
|
37
69
|
if (!input.title.trim()) {
|
|
38
70
|
throw new Error('Note title cannot be empty.');
|
|
@@ -46,9 +78,4 @@ export const validateNoteInput = (input) => {
|
|
|
46
78
|
if (input.content.length > maxContentLength) {
|
|
47
79
|
throw new Error(`Note content is too large. Maximum length is ${maxContentLength} characters.`);
|
|
48
80
|
}
|
|
49
|
-
const findings = findSensitiveContent(`${input.title}\n${input.content}`);
|
|
50
|
-
if (findings.length > 0 && !input.allowSensitive) {
|
|
51
|
-
const labels = Array.from(new Set(findings.map((finding) => finding.label))).join(', ');
|
|
52
|
-
throw new Error(`Sensitive memory blocked (${labels}). Remove secrets or pass --allow-sensitive intentionally.`);
|
|
53
|
-
}
|
|
54
81
|
};
|
|
@@ -155,7 +155,10 @@ export const writeContextPack = async (vaultPath, key, context, meta) => {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
};
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// Context packs cache raw note/chunk content in plaintext, so keep them
|
|
159
|
+
// owner-only (0o700 dir, 0o600 file) to match the rest of the vault-on-disk
|
|
160
|
+
// hardening rather than inheriting a world-readable umask default.
|
|
161
|
+
await mkdir(contextPacksDirectory(vaultPath), { recursive: true, mode: 0o700 });
|
|
162
|
+
await writeFile(path, `${JSON.stringify(stored, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
160
163
|
return path;
|
|
161
164
|
};
|
|
@@ -17,6 +17,10 @@ const keyFilePath = (vaultPath) => {
|
|
|
17
17
|
return join(getBrainlinkHomePath(), 'keys', `vault-versioning-${vaultHash}.key`);
|
|
18
18
|
};
|
|
19
19
|
const deriveKeyFromSecret = (secret) => createHash('sha256').update(secret, 'utf8').digest();
|
|
20
|
+
// Derive a 32-byte AES key from a user-supplied passphrase. Used for portable
|
|
21
|
+
// encrypted snapshots/bundles that must decrypt on another machine carrying only
|
|
22
|
+
// the passphrase (not the per-vault keyfile).
|
|
23
|
+
export const deriveVaultKeyFromPassphrase = (passphrase) => deriveKeyFromSecret(passphrase);
|
|
20
24
|
// Load the vault's versioning key, generating a fresh 48-byte secret on first
|
|
21
25
|
// use. BRAINLINK_VAULT_KEY overrides the file (useful to restore an encrypted
|
|
22
26
|
// vault on another machine by carrying just the secret, not the whole repo).
|
|
@@ -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({
|
|
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, {
|
|
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