vault-brain
v0.3.1
Published
A fast, local-first encrypted Markdown knowledge engine with controlled AI access, backlinks, history, attachments, templates, and portable YAML frontmatter.
Maintainers
Readme
Vault Brain
A least-exposure,
.env-style personal data vault for the AI age.
Project direction: this MVP is evolving into a desktop-first, local-first, encrypted knowledge workspace under the motto “Faster to recall. Safer to trust.” See
docs/PRODUCT.md,docs/ARCHITECTURE.md, anddocs/ROADMAP.md.
Compatibility: Vault Brain is the product and CLI name. Existing vault storage, cryptographic, plugin-signature, sync, and writer-lock identifiers remain immutable so data created by earlier releases stays readable.
TL;DR (TR): Obsidian gibi kişisel bilgi yönetimi araçları verini (sağlık, kimlik,
finans) düz metin olarak AI ajanlarına besliyor. Vault Brain, veriyi
şifreli anahtar-değer çiftleri olarak saklar; AI önce sadece anahtar adlarını
ve açıklamaları görür (değerleri değil), ihtiyaç duyduğu tek değeri talep
eder, ve her erişim denetim kaydına (audit log) yazılır.
Why
Personal-knowledge-management tools are increasingly wired straight into AI agents. The common pattern — a graph of free-text notes, fully readable by whatever model touches it — means every query potentially exposes your whole vault, not just the fact you asked about.
This project borrows a habit developers already trust: .env files. We
don't hand an agent a whole config file to "figure out" — we let it ask for
one variable at a time. Vault Brain applies the same discipline to
personal data.
Two modes — read this before you wire it into anything
This is the most important section in the README.
| | Mode 1 — vbrain get (Direct) | Mode 2 — vbrain mcp (AI-assisted) |
| ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Who's involved | You, or a script. No LLM. | An AI agent (e.g. Claude) via MCP |
| Exposure | Zero — value never enters any model's context | Not zero — content does pass through the calling model's context, both when you tell the agent something worth saving (store_note) and when it resolves a value back (resolve_key) — that's inherent to how a conversational agent has to work |
| What the agent sees without decrypting anything | N/A | Key names + descriptions (list_keys / find_key), and note timestamps + tags (find_notes_in_range) |
| Audit | Logged | Logged — writes and reads both, including denials |
| Scoping | N/A | Required per-agent grants: which keys, which actions, for how long, how masked |
Mode 2 is a deliberate trade-off, not a zero-exposure claim: you get an agent that can capture and recall notes for you in natural language, in exchange for that agent seeing what you tell it in that moment. What it does not get is standing access to your whole vault — every other note stays encrypted and out of its context until it specifically asks for that one key, and every access (read or write) is logged. If you want a hard guarantee that something never touches any LLM, keep using Mode 1 for it.
Grants: narrowing Mode 2 to one agent, one slice, one answer
MCP access is fail-closed. A vault with no grant policy cannot start the MCP server. Add a grant first; from then on an agent gets only what a live grant gives it.
# A kitchen assistant that may read one key, in one category, for a week.
vbrain grant add claude-code \
--scope "health:BLOOD_TYPE:discover,resolve:none" \
--expires 7d --note "meal planning"
# The same agent may see that an IBAN exists and confirm the last four digits,
# but never read the whole thing.
vbrain grant add claude-code --scope "finance:IBAN:discover,resolve:partial"
# Anything under this grant waits for you before it is answered.
vbrain grant add research-bot --scope "*:*:discover:full" --confirm
vbrain grant list # every grant, active, expired or revoked
vbrain grant requests # resolutions waiting on you
vbrain grant approve <id>... # one or more; single-use, expires in five minutes
vbrain grant revoke <id> # effective on the agent's very next callA scope reads file:keys:actions[:redaction]. Keys are exact names, a
PREFIX* glob, or *; actions are discover, resolve and store;
redaction is none, partial (mask identifiers, keep a short tail so the agent
can confirm a match) or full (return the value's shape — "an IBAN, 26
characters" — and none of its characters).
Choose redaction per key, not per file. partial recognises identifiers —
an IBAN, a card or phone number, an email, a long opaque id — and keeps a short
tail. Anything it does not recognise gets the same treatment: every character
but the last few is replaced. That is the right default for an unknown secret
and the wrong one for a name or a date, which come back as •••••••••••raca
and answer nothing. Applied to one identifier it is exactly the intent;
stretched across a file with health:*:...:partial it masks every ordinary
value in that file, and the agent reports that it could not find answers the
grant does permit. vbrain grant add prints a note when a scope does this.
# Readable where reading is the point, masked where it is not.
vbrain grant add claude-code \
--scope "health:DOCTOR_NAME:discover,resolve" \
--scope "health:BLOOD_TYPE:discover,resolve" \
--scope "health:INSURANCE_POLICY:discover,resolve:partial"Three properties are worth stating plainly:
- The narrowest scope wins. When several scopes cover one key, the strictest redaction is applied, so adding a broad convenience grant can never quietly unmask something you already chose to hide.
- An approval is single-use. One "yes" answers one resolution and is then spent; it never becomes a standing permission.
- Redaction narrows exposure, it does not create a boundary. A masked value
still crosses into the model's context as a masked value. The vault owner
pins the grant identity with
mcp --agent; it is never accepted from tool request data. Anything that must never reach a model belongs in Mode 1.
Grants live encrypted in grants.enc inside the vault, so the agent names, the
scopes and the pending approvals are all unreadable without the passphrase.
Format
# @desc: Bir sonraki doktor kontrol tarihi
DOCTOR_NEXT_APPOINTMENT="2026-09-15"
# @desc: Kan grubu
BLOOD_TYPE="0 Rh+"- Files live as
vault/<name>.kv.enc— AES-256-GCM encrypted, scrypt-derived key from your passphrase. The plaintext above never touches disk. - Key names and
@desccomments are copied into encryptedschema.encbyvbrain index; values are never included. Never put sensitive information in a description — the tool can't enforce that for you.
Entering many keys at once
vbrain add <file> --from <path.kv> reads exactly the shape above and writes
the whole file in one transaction. Adding keys one command at a time costs
about 620 ms each, almost all of it fixed: process start, the module graph and
the scrypt keyring unwrap, all paid again for every key. Measured, 250 keys
took 158 seconds that way and 2.1 seconds through --from.
vbrain add health --from ./health-entries.kv
# Stored 250 entries in health.kv.enc (encrypted): 250 new, 0 replaced.- All or nothing. Every entry is validated before anything is written, so one bad key writes none of them rather than leaving the category half updated. A key repeated inside one file is refused too: "last one wins" is a rule you did not necessarily intend.
- Existing keys are replaced in place; an entry with no
@desckeeps the description already stored. - The audit chain records each key, not one line for the batch.
- The source file is plain text. It holds your values unencrypted on disk until you delete it, and the command says so when it finishes.
Quickstart
The npm package is named vault-brain; installing it globally exposes the
short, project-specific vbrain command:
npm install --global vault-brain
vbrain --version
vbrain --helpThe package is published from CI with provenance: the registry carries a signed
statement tying the tarball to this repository, the publishing workflow and the
commit it was built from. npm view vault-brain dist shows the attestation.
The desktop application is not in this package and is not released yet — see
docs/ROADMAP.md.
For local development from this repository:
npm install
npm run build
export VBRAIN_PASSPHRASE="use-a-real-passphrase-here"
node dist/cli.js --vault ./vault/personal init
node dist/cli.js --vault ./vault/personal add health 'DOCTOR_NEXT_APPOINTMENT="2026-09-15"' --desc "Bir sonraki doktor kontrol tarihi"
# many at once, from a plain .kv file — see "Format" below for the shape
node dist/cli.js --vault ./vault/personal add health --from ./health-entries.kv
node dist/cli.js --vault ./vault/personal index
# Mode 1 — direct, zero-exposure
node dist/cli.js --vault ./vault/personal get health DOCTOR_NEXT_APPOINTMENT
# fast path — search the value-free schema
node dist/cli.js --vault ./vault/personal search "doktor"
# freeform journal entry (dev/testing — normally an agent calls store_note for you)
node dist/cli.js --vault ./vault/personal note health "Check-up yaptırdım, sonuçlar normal." --desc "Doktor ziyareti notu"
# browse notes by date after unlocking the encrypted catalog
node dist/cli.js --vault ./vault/personal timeline --category health
# Mode 2 — start the MCP server for an agent (Claude Code, etc.)
node dist/cli.js --vault ./vault/personal mcp --agent claude-codeA working demo vault (dummy data only) is checked in at vault/example/.
Exit codes
Scripts and CI chain on these, so they describe the result rather than the
output format — --json and the human-readable form of the same command always
exit the same way.
| Code | Meaning |
| ---: | ------------------------------------------------------------------------------ |
| 0 | The command did what it was asked to do. |
| 1 | It failed: bad arguments, wrong passphrase, a damaged or busy vault. |
| 2 | It ran, reported honestly, and did not do the work. |
Code 2 covers a failed audit verification, purge without --yes, a
docs verify that found integrity errors, and a sync apply blocked by
unresolved heads. Treat 2 as "stop the pipeline", not as success with a
warning.
Unlocking, locking and the storage format
# Prove the passphrase, and optionally hand it to the OS credential store
node dist/cli.js --vault ./vault/personal unlock --remember
# Which store is in use, and what this vault currently holds
node dist/cli.js --vault ./vault/personal keychain-status
# Explicitly end it: the remembered passphrase is forgotten
node dist/cli.js --vault ./vault/personal lock
# Rewrite pre-versioning files in the current encrypted envelope
node dist/cli.js --vault ./vault/personal migrate
# Re-wrap the keyring under a new passphrase, at the current KDF cost
node dist/cli.js --vault ./vault/personal passphrase change
# Inspect public slot metadata; no passphrase is needed
node dist/cli.js --vault ./vault/personal keyring status
# Create an offline recovery kit outside the vault
node dist/cli.js --vault ./vault/personal keyring recovery create --out ../personal-recovery.json
# Restore a missing or damaged keyring and choose a new primary passphrase
node dist/cli.js --vault ./vault/personal keyring recovery restore --from ../personal-recovery.json
# Replace the vault's keys and re-encrypt every object under them
node dist/cli.js --vault ./vault/personal rekeyEncrypted files carry an explicit envelope version, cipher name and the exact
scrypt parameters they were written with, and those header fields are
authenticated together with the ciphertext — an attacker cannot edit the
recorded cost factor to weaken the next derivation without the tag check
failing. Files written before versioning existed still open, and vbrain
migrate upgrades them in place. test/fixtures/ holds vaults written by
earlier formats so a future change has to prove it can still read them.
vbrain passphrase change— change the vault passphrase. The keyring is re-wrapped at the current key-derivation cost, so this is also how a vault created under an older, cheaper setting raises its work factor. Nothing is re-encrypted, and it takes the same time on a 100,000-note vault as on an empty one. Add--allow-same-passphraseto raise the cost without changing the passphrase. It never consults the OS credential store for the current passphrase, so running it unattended requires bothVBRAIN_PASSPHRASE(the current passphrase) andVBRAIN_NEW_PASSPHRASE(the replacement) to be set. If this vault has a passphrase remembered in the OS credential store, it is updated to the new one.vbrain keyring status [--json]lists slot IDs, labels, creation times and scrypt costs without unlocking the vault. It never prints salts or wrapped ciphertext.vbrain keyring recovery create --out <file>adds one recovery-labelled slot and writes its independently usable recovery kit outside the vault. The generated 256-bit code is shown once and must be stored separately from the kit. Neither the kit nor the keyring stores the code.vbrain keyring recovery restore --from <file>opens the kit with its code, verifies the recovered keys against the selected vault, preserves the old keyring as a ciphertext backup and installs a freshly wrapped primary slot.vbrain keyring recovery remove --slot <id>detaches a live slot; it cannot invalidate offline copies of a kit. Rotate content keys before creating a replacement kit when a kit and its code may have leaked.
Key-material commands add paired pending and allowed/denied entries to
the same authenticated audit chain as content access. Recovery codes, kit
paths and key material are never included in those entries.
vbrain rekeyreplaces the vault's keys and re-encrypts every object under them. Use it when a passphrase has leaked: a passphrase change re-wraps the same keys, so the leaked passphrase would still open a copy ofkeyring.jsontaken before the change. It asks for a new passphrase, and--keep-passphraseskips that when the keys, not the passphrase, are what is suspect. It drops every keyring slot the passphrase you give it cannot open: a recovery slot, or a second person's slot, stops working the moment the re-key commits, so re-add it afterwards. Attachment identities, sync change IDs and the audit chain are deliberately unchanged, so attachments keep their content addresses, peers keep converging andvbrain audit verifykeeps validating the whole chain.
Encrypted Markdown documents
The new document engine stores Markdown notes as individually encrypted objects with stable IDs and revisions. Its search/link index is encrypted too; titles, note bodies, tags and search tokens are not written to disk as plaintext.
# Create two linked notes
node dist/cli.js --vault ./vault/personal docs put Projects/Alpha \
--title "Project Alpha" \
--body "Launch plan owned by [[People/Ada]]. #project/active"
node dist/cli.js --vault ./vault/personal docs put People/Ada \
--title "Ada" \
--body "Works on [[Projects/Alpha]]. #person"
# Fast encrypted-index recall and knowledge links
node dist/cli.js --vault ./vault/personal docs search "launch tag:project/active"
node dist/cli.js --vault ./vault/personal docs backlinks Projects/Alpha
node dist/cli.js --vault ./vault/personal docs links Projects/Alpha
# Directly decrypt one note, or emit portable Markdown with frontmatter
node dist/cli.js --vault ./vault/personal docs get Projects/Alpha
node dist/cli.js --vault ./vault/personal docs get Projects/Alpha --with-frontmatterUse docs list, docs import, docs rebuild-index, and docs remove for the
remaining document lifecycle operations. These are direct CLI operations; no
model sees their output unless you explicitly pipe it into one.
Search query language
docs search takes the following. Terms combine with AND unless separated by
OR, and - negates whichever term it is attached to — including the filters.
| Query | Matches |
| -------------------------------- | ---------------------------------------------------- |
| launch plan | both words appear somewhere |
| "launch plan" | that exact phrase |
| launch* | a word starting with launch (a bare word is a plain substring, so apple also matches grapple; appl* does not) |
| launch OR rollout | either |
| launch AND rollout | both; the same as writing them side by side |
| tag:project/active tag:proj* | tag, exactly or by prefix; a leading # is optional |
| path:Projects/ file:Alpha | substring of the full path, or of the basename |
| [status] | the note has a status property |
| [status:done] [status:do*] | the property holds that value |
| created:2026-01-15 | created on that UTC day |
| created:2026-01-01..2026-06-30 | created in that inclusive range |
| updated:>=2026-07-01 | modified on or after that day (<= also works) |
| -tag:archive | excludes; likewise -[status:done], -"draft" |
OR and AND are operators only when written alone and in capitals, so a note
about the word "or" is still searchable. An operator whose argument is unusable
— an empty tag:, an unparseable date — falls back to plain text rather than
failing the query, so a search box narrows as you type.
Revision recovery and encrypted attachments are built in:
node dist/cli.js --vault ./vault/personal docs history Projects/Alpha
node dist/cli.js --vault ./vault/personal docs restore Projects/Alpha 1
node dist/cli.js --vault ./vault/personal docs unresolved
node dist/cli.js --vault ./vault/personal docs rename Projects/Alpha Archive/Alpha
node dist/cli.js --vault ./vault/personal docs attach ./diagram.png --mime image/png
node dist/cli.js --vault ./vault/personal docs attachments
node dist/cli.js --vault ./vault/personal docs attachment-get <id> ./restored-diagram.pngSemantic recall is available as an explicit, on-device path and never changes ordinary full-text search. With an embedding model already available in a local Ollama instance:
node dist/cli.js --vault ./vault/personal docs semantic-search \
"where did I write about fixing a car?" \
--model nomic-embed-text \
--url http://127.0.0.1:11434The command unlocks the vault, sends at most 16,000 characters per note to the
chosen local model, and keeps its revision-aware vector index only in process
memory. --max-characters, --min-score, and --limit tune that boundary and
the result set. Model URLs must be literal HTTP loopback addresses and redirects
are refused; a remote model cannot be enabled accidentally. Locking clears the
vectors, and successful semantic searches add a value-free audit event. The
exported OllamaLocalModelAdapter also provides a non-streaming local
generate() boundary for host integrations. Treat the local model process as
trusted: it receives the bounded plaintext needed for the operation.
An existing Obsidian vault can be migrated as one checked operation. Markdown
and frontmatter are preserved, ordinary files become content-addressed encrypted
attachments, and JSON Canvas boards bind to the imported note/attachment IDs.
Hidden directories such as .obsidian and .git are skipped by default.
node dist/cli.js --vault ./vault/personal docs import-obsidian ./MyObsidianVault \
--report ./obsidian-integrity-report.jsonThe report records malformed notes/canvases, skipped symbolic links, ambiguous
attachment names, unresolved wikilinks and missing local Markdown links. The
report itself contains source paths and is plaintext, so store or remove it as
you would any other migration log. The encrypted destination must be outside
the source vault, preventing an import from recursively consuming its own
output. Use --include-hidden only when hidden source content is intentional.
The same shape comes back out. vbrain export writes every note as Markdown
with frontmatter, every canvas as JSON Canvas, and every attachment into an
assets folder beside them — the shape import-obsidian reads, so a vault can
be exported and re-imported without losing note identity.
node dist/cli.js --vault ./vault/personal export ./plain-copy \
--report ./export-report.jsonThe export is plaintext: everything the vault encrypts is readable by anyone
who can read that directory. It refuses a destination inside the vault, and
refuses to merge into a directory that already holds files. Two things the
vault permits that a filesystem does not — a note path such as Q3: results.md
or aux.md, and two attachments sharing one filename — are renamed rather than
dropped, and the report names every rename. The vault's audit chain records
that an export happened and how much it carried, but not where it was written.
Backing up, and proving the backup works
A backup is one file. It carries every encrypted object, the journals and the
vault's keyring.json, so it opens with the vault passphrase and needs nothing
else — no key file, no earlier copy, nothing from this machine.
node dist/cli.js --vault ./vault/personal backup ./backups/personal-2026-09-05.vbrainbackup
node dist/cli.js restore ./backups/personal-2026-09-05.vbrainbackup ./restored --verify-onlyThe procedure:
- Take the backup.
vbrain backup <archive>writes the archive and then reads it back through the restore path before reporting success. It refuses to overwrite an existing file, so each run names a new archive. - Move it somewhere that survives losing this machine. A host that stores the archive sees its total size and the keyring header — slot labels, key derivation cost, creation dates — and nothing else. Not the number of notes, not their paths, not how many revisions any of them has: the file list is sealed with the contents. The passphrase is not in the archive, and no one holding only the archive can open it.
- Verify what you stored, where you stored it.
vbrain restore <archive> <destination> --verify-onlychecks the seal over the header, the AEAD tag on every entry and every entry's hash, and writes nothing. Run it against the stored copy, not the one you just made: what you are testing is the storage. - Restore into a new directory.
vbrain restore <archive> <destination>stages the whole verified vault beside the destination and moves it into place only when every entry has checked out. It refuses a destination that already holds files, so a restore cannot replace a vault that may still be good.
What a backup is not: an answer to a forgotten passphrase. Both the vault and
its backups are wrapped by the same passphrase, so losing it loses all of them
together. That is what the recovery kit is for — vbrain keyring recovery
create — and the kit and the backup should not live in the same place. A
backup answers a lost or corrupted vault; the kit answers a lost passphrase.
A restored vault's audit chain ends where the backup was taken. Nothing that happened after it is in the archive, including the audit entry recording that the backup itself was made.
npm run recovery:drill exercises this path end to end against a disposable
vault: it takes an archive, restores from it, catches up from a relay and
verifies an owner-signed checkpoint.
Deleting, and bounding history
docs remove archives the outgoing revision before it unlinks a note, so a
removed note is recoverable with docs restore — and its content is still in
the vault. That is right for an editing mistake and wrong for "this should
never have been written down".
# A preview: prints what would go, changes nothing, exits non-zero.
node dist/cli.js --vault ./vault/personal purge note "Health/Results.md"
node dist/cli.js --vault ./vault/personal purge note "Health/Results.md" --yes
# Bound how much history every object keeps, and apply it to what is already there.
node dist/cli.js --vault ./vault/personal retention set --keep-revisions 10 --keep-days 365
node dist/cli.js --vault ./vault/personal retention showA purge removes the object and every archived revision of it, and records the id in the audit chain. It is not recoverable from that vault. It also does not reach backups taken earlier, this vault's own sync change log, a relay the changes were pushed to, or a device that already pulled them — and a purge does not propagate to other devices, which was a decision rather than an omission.
Read docs/DELETION.md before relying on any of this.
It states each of those limits, and records why 1.x ships no deletion tombstone.
Retention defaults to unlimited: a new build must not silently discard history someone already has. Both cores honour the policy the vault carries, so the desktop application prunes to the same bound the command line set.
Performance gates
Saving one note no longer costs a pass over the vault. Phase 17 replaced the whole-index rewrite on the save path with an encrypted change log, in both the TypeScript library and the Rust desktop core. Incremental save acknowledgement is specified at < 20 ms p95, at 100,000 notes. Both cores now measure well inside it — 4.55 ms (TypeScript) and 5.98 ms (desktop) at that size — and a save costs the same there as it does at 1,000 notes. At 10,000 the TypeScript path went from 141.7 ms to 3.5 ms and the desktop core from 2,553 ms to 5.0 ms. See Phase 17 in
docs/ROADMAP.md.
npm run benchmark # 1,000 notes — the everyday regression gate
npm run benchmark:10k # 10,000 notes
npm run benchmark:100k # 100,000 notes (slow: it writes 100k encrypted objects)
npm run benchmark:budgets # strict: fails when a budget is missed
npm run benchmark:rust # the same, for the Rust desktop coreEach builds a disposable encrypted corpus and enforces the p95 budgets from
docs/PRODUCT.md at that size: unlock, quick-switch,
full-text search, note open and backlinks. The gates are measured, not
aspirational — a tier's numbers are only raised with a measurement and a
reason, never to turn a red run green.
unlockAndIndexMs is timed until the index is actually usable, not until the
constructor returns: new DocumentVault is lazy, so stopping the clock there
measured an unlock that had not yet decrypted an index. It is reported as the
p50/p95/max of five cold unlocks — each in its own process, because a second
unlock in the same process measures a warm one — and the budget gates the
median. A cold unlock reads and decrypts the whole index — 140 MB at the 100k
tier, because the index carries every note body — so a single sample of it
reports the runner as much as the vault: fourteen consecutive samples from CI
ranged 1,087–2,074 ms around a 1.78 s median, and the one that crossed the
2 s budget failed a gate the other thirteen passed. Five samples on one
development machine span about 30 ms (1,419–1,451 ms at the 100k tier),
against that 1,000 ms spread.
Incremental save is always measured and always reported. It is gated on
the median of 200 samples and on the worst of them (< 1 s), not on p95, and
p95 is printed in every run — with a TAIL: line whenever it crosses the
20 ms product budget. A save is four durable file operations, and on a shared
CI disk a small fraction of fsyncs stall for hundreds of milliseconds: across
thirty measurements the median never left 2.2–4.9 ms while the worst sample
ranged 4 ms to 493 ms, so p95 reported that run's stall rate rather than the
save path and twice turned the job red without the path changing. More samples
do not fix that. Any run that misses the gated numbers prints a BUDGET MISS
line, and the success line says so rather than claiming a clean pass.
npm run benchmark:budgets fails on a miss; npm run benchmark:rust does the
same for the desktop core, whose save path is a different implementation of the
same contract and which gates the same statistics. The performance-budgets CI
job runs both, so neither core can pass a budget the other fails.
Building the larger tiers is what surfaced the work behind them. Three write and resolve paths scanned the whole vault (quadratic during a bulk import), and search re-normalized every note body inside the query loop, so a 10,000-note corpus took minutes to build and 80 ms to search. With reverse lookups in the index and search text normalized once per session, the same corpus builds in seconds and answers a full-text query in about 3 ms p95.
Native desktop workspace
The repository now includes a Tauri 2 desktop application. Its React webview has no direct filesystem or key access; fifty-one capability-scoped commands cross into the Rust core for unlock, lock, the whole note lifecycle, search, backlinks, value-minimized graph topology, typed property rows, templates and daily notes, encrypted canvases, content-addressed attachments and sandboxed plugins. The session key stays in Rust memory and is zeroized on lock.
# Browser-backed UI development (uses a safe in-memory demo vault)
npm run desktop:dev
# Full native application against encrypted vault files
npm run tauri:dev
# Produce native host bundles (MSI/NSIS, app/DMG, or deb)
npm run tauri:build
# Faster local release check without installer generation
npm run tauri:build -- --no-bundleApplication updates are deliberately manual. Open Updates, then separately approve the check, download, and final installation. The native controller owns the fixed GitHub Releases feed, supported bundle target, timeout, downloaded bytes, and mandatory Tauri signature verification; the webview never receives an update URL, signing key, or installer path. Installation first flushes note and canvas edits, aborts if either save fails, and locks and clears the vault before the verified package is applied.
Builds installed before the updater was added need one manual bootstrap install. The free release path does not provide Authenticode or Apple publisher identity, so Windows SmartScreen or macOS Gatekeeper may warn even though in-app updates remain cryptographically signed. Back up the updater private key offline: losing it can force another manual reinstall. Updates have no automatic rollback and remain optional, so an unavailable release channel never blocks offline work. Maintainers should follow the release runbook for signing-key provisioning, draft verification, the real cross-platform update drill, and publish.
The current workspace includes a lock screen, file tree, tab strip, CodeMirror Markdown editor, reading view, split pane, properties, outline, backlinks, encrypted search, quick switcher, command palette, keyboard shortcuts, theme editor, local knowledge graph, filterable property table, spatial canvas, attachment library and a manual updater. Dirty notes are persisted before navigation, before a tab closes and before lock. Canvas and note saves are also drained before updater installation, so neither debounce window nor an in-flight write can race the vault lock.
| Shortcut | Action |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| Ctrl+O | Quick switcher — title, path or alias; alt+↵ opens it in the split pane |
| Ctrl+K | Command palette |
| Ctrl+Shift+F | Search the encrypted vault |
| Ctrl+\ | Open the current note in the split pane |
| Ctrl+W | Close the active tab (saving it first if dirty) |
| Ctrl+D | Open (or first create) today's daily note |
| Ctrl+N / Ctrl+S / Ctrl+E / Ctrl+L | New note · save · write/read · lock |
A note's whole life is available from the document toolbar and the command palette, not only from the CLI:
- Move or retitle. A note keeps its ID, so its history and every link that resolved to it by ID survive the move. Wikilinks that spelled out the old title stay as written and become unresolved mentions, which the context panel can relink in one click.
- Delete and restore. Deleting archives the live revision before unlinking the encrypted object, so the note is still recoverable from Restore a deleted note in the command palette.
- History. Every revision is a separate encrypted object. The history dialog decrypts one at a time to read, and restoring writes the old content forward as a new revision rather than rewinding the counter, so nothing already archived is invalidated.
- Templates and daily notes. Any note tagged
templateshows up in New note from a template, with the same{{title}},{{path}},{{date:YYYY-MM-DD}},{{time:HH:mm}}and custom variables the CLI renders.Ctrl+Dopens today's daily note, creating it only the first time.
The sidebar's Canvas and Files views round out the workspace without ever handing the webview a file path:
- Canvas. A board of freeform text cards, group frames, web links, and file nodes that point at a vault note or an encrypted attachment by id. Nodes are dragged on a fixed surface, connected with directed edges, and written back as one encrypted canvas document under the same stale-revision check that guards notes — a canvas edited elsewhere is rejected rather than overwritten. Edits autosave 900 ms after the last change, and a node whose target has since been deleted renders as a missing reference rather than disappearing.
- Files. The attachment library adds files by encrypting their bytes in the Rust core, and decrypts one only when it is previewed or downloaded. Images, audio, video, PDFs and text preview inline from an object URL that is revoked as soon as the preview closes.
Plugins, without Obsidian's trust model
An Obsidian plugin runs as arbitrary Node inside the app: it can read your whole filesystem, and no amount of encryption at rest survives that. Here a plugin is a manifest and one JavaScript file, and its reach is finite and declared.
{
"manifestVersion": 1,
"id": "word-count",
"name": "Word count",
"version": "0.2.0",
"description": "Shows how many words the open note holds",
"author": "example",
"capabilities": ["notes:read", "ui:panel", "commands"]
}vbrain plugins install ./plugin.json ./main.js # installs, but leaves it off
vbrain plugins list # what is installed and what it may reach
vbrain plugins enable word-count
vbrain plugins remove word-count # takes the plugin's settings with itPublishers can bind the manifest and exact source bytes into one Ed25519 package. The public key travels in the signature; the private key never enters the vault:
vbrain plugins keygen ./publisher.private.pem
vbrain plugins sign ./plugin.json ./main.js \
--key ./publisher.private.pem --out ./plugin.signed.json
vbrain plugins install ./plugin.signed.json ./main.js
# Signed-only operation and local emergency revocation
vbrain plugins restricted on
vbrain plugins revoke-signer word-count
vbrain plugins policy
vbrain plugins restore-signer <64-character-key-id>Restricted mode refuses unsigned installation and prevents already-installed unsigned packages from running. Revocation is keyed by the SHA-256 fingerprint of the publisher's Ed25519 public key, so every installed package from that key stops on the next plugin refresh; the encrypted policy travels with the vault. The first accepted signing key is pinned to that plugin ID, and an update from a different key is refused until the old plugin is explicitly removed. Signatures prove that the source and manifest have not changed since the holder of that key signed them. They do not prove who owns a self-declared key, so the first trust decision still belongs to the person installing the plugin.
The capabilities are notes:metadata, notes:read, notes:write, search,
canvas:read, canvas:write, attachments:read, commands, ui:notice,
ui:panel and storage. There is deliberately no network capability: it is not
offered and cannot be requested. A manifest naming a capability this build does
not know is refused, not trimmed — installing it would mean showing you a
list that understates its reach.
Three things make that list mean something:
- It runs in a worker with no DOM, no filesystem and no network.
fetch,XMLHttpRequest,WebSocketandimportScriptsare removed before the plugin's first line runs, and the plugin is loaded as a worker script rather thaneval'd — so the app never has to grant'unsafe-eval'to anything. - Every call is checked against the manifest. A method that isn't in the capability table has no capability and is refused, so a host method added without one is unreachable rather than accidentally public.
- The Rust command layer is the actual boundary, and it does not trust the worker or the host. A plugin that escaped the sandbox entirely would still hold only the capabilities the webview itself has — and the vault key never enters the webview at all.
What this does not do is protect you from a plugin abusing what you granted.
notes:read means it reads your notes. The defence there is the capability list
you approve before it runs, and the switch that turns it off.
Plugins and the restricted/revocation policy live encrypted in the vault like everything else, so they travel with it, and one cannot be swapped on disk without the passphrase. Both the TypeScript CLI core and the Rust desktop core verify the same length-prefixed signature payload before a signed package is accepted or loaded.
Two session guards protect an unlocked window you walked away from:
- Inactivity lock. The vault re-locks after a configurable idle window — 1, 5 (default) or 15 minutes, or disabled — set from the sidebar's Auto-lock control. Keystrokes, pointer input and scrolling reset it; the lock screen then says why it locked.
- Self-clearing clipboard. Copying a note or a single property value marks the clipboard as workspace-owned and rewrites it to empty 30 seconds later, and again on lock — but only if the clipboard still holds exactly what was copied, so it never wipes something you copied elsewhere in the meantime. If the webview denies clipboard read access, the copy still works and the workspace tells you it cannot take the value back.
The file tree and the property view are windowed: they keep a viewport's worth of rows in the document no matter how many notes the vault holds, so a 40,000-row folder costs the same to render as a 40-row one. (The interface is bounded; the encrypted core's own 10k/100k budgets are still only measured against the 1,000-note benchmark corpus.)
The theme editor — sidebar palette icon, or Customize theme in the command
palette — derives the whole interface from four colours (chrome, surface, ink,
accent) plus a reading size and typeface, with four presets to start from. It
shows the WCAG contrast ratio for ink-on-surface and accent-on-chrome as you
edit and flags a combination that drops below AA. The theme is a per-device
preference stored in localStorage; it never enters the encrypted vault.
Obsidian-style YAML frontmatter, safe templates and daily notes are supported:
# Mark any encrypted note as a template and use familiar variables such as
# {{title}}, {{date:YYYY-MM-DD}}, {{time:HH:mm}} and custom {{name}} values.
node dist/cli.js --vault ./vault/personal docs from-template \
Templates/Meeting Meetings/Kickoff \
--title "Kickoff" --date 2026-08-30 --var client=Acme
# Idempotent: the first call creates the note; later calls open the same note.
node dist/cli.js --vault ./vault/personal docs daily 2026-08-30 \
--folder Journal --template Templates/DailyImported Markdown accepts nested YAML properties, block scalars, tag/alias lists and common scalar values. YAML aliases are deliberately disabled and duplicate or prototype-shaping keys are rejected before anything enters encrypted storage.
Wiring into Claude Code / an MCP client
{
"mcpServers": {
"vault-brain": {
"command": "node",
"args": [
"/absolute/path/to/secondbrain-vault/dist/cli.js",
"--vault",
"/absolute/path/to/vault/personal",
"mcp",
"--agent",
"claude-code"
],
"env": {
"VBRAIN_PASSPHRASE": "use-a-real-passphrase-here"
}
}
}
}Once connected, you don't type CLI commands yourself — you just talk. Tell
the agent "bugün doktora gittim, tahliller normaldi, 6 ay sonra tekrar
kontrol" and it calls store_note on your behalf. Ask "bir sonraki doktor
kontrolüm ne zaman?" and it calls find_key → resolve_key. The CLI's own
add/note/get commands still exist, but they're there for scripting and
testing — not the intended everyday interface.
The server exposes six tools:
| Tool | Touches values? | Gets audited? |
| --------------------- | ------------------------- | ------------- |
| list_keys | No | No |
| find_key | No | No |
| find_notes_in_range | No — timestamps/tags only | No |
| store_note | Yes — that's the point | Yes |
| resolve_key | Yes | Yes |
| resolve_keys | Yes — up to 20 named keys | Yes, one line per key |
Under a grant policy the three value-free tools return only the keys that
agent may discover, store_note needs a store action, and resolve_key
additionally records who asked, under which grant, how much came back, and
whether the answer was allowed, denied, held for approval, asked for a key
that does not exist, or failed with an error. An error line is value-free like
the others: it names the file and key asked for, never the error message. A
category named in the wrong case (HEALTH for a stored health) that the
grant covers under its stored spelling is denied with that spelling, rather
than with advice to widen the grant.
resolve_keys is resolve_key for several keys at once: the agent names each
{file, key} pair, up to twenty, and gets them back in one answer instead of
one tool call each. It adds no reach. Every key goes through the same grant
decision, approval hold and mask as it would alone, and the audit chain records
each key, not one line for the batch. A list that is empty, too long, or names a
key twice is refused before anything is decrypted. Each entry in the answer
opens with a marker drawn fresh for that response, so a stored value cannot pass
itself off as a different key's entry.
The three value-free tools answer with one entry per line, each naming its own
file so the next resolve_key call cannot take a key from the wrong one:
health/DOCTOR_NAME — family doctor
health/BLOOD_TYPE — blood type
health/NOTE_20260920_212739_0e78eb198dd6 — 2026-09-20T21:27:39.000Z — checkup summary
work/CURRENT_PLAN — updated 2026-09-26 — this quarter's planA journal note carries the time its key encodes. A fact carries the day it was
last written, so an agent can tell a plan rewritten yesterday from one left
untouched for a month. Neither the .kv format nor the catalog stores a
timestamp, so that day comes from the audit trail: the latest signed CLI or MCP
write of the key, read only when the whole chain verifies. A fact never written
through the CLI or MCP on this machine, including one that arrived only by
sync, shows no date.
Discovery is the largest thing this server puts into a context — it lists every key the agent may see, on every conversation that browses the vault — so the format is chosen for what it costs a model to read. It used to be pretty-printed JSON, which spent about a third of its characters on indentation and quoting; on a 250-key vault that made listing one key cost more than handing over that key, its description and its value would have, which inverts the reason an agent discovers before it resolves.
store_note covers both shapes: pass an explicit key for a fact that
should overwrite itself (like IBAN), or omit it for a freeform journal
note — it gets an auto-generated, timestamp-prefixed key (NOTE_20260830_...)
so entries stack up instead of colliding. find_notes_in_range then lets an
agent browse "what did I note about health in August" using only that
timestamp, with zero decryption — the note-taking equivalent of Obsidian's
daily-notes view, but without reading your notes to build it.
The key is decided before permission is checked, so a journal note needs a
grant whose scope actually covers the generated name — NOTE_* or *. A
grant scoped to one exact key (health:IBAN:store) permits writes to that key
and nothing else, including auto-named notes.
What MCP does not reach: this surface is key-value only
These five tools are the whole AI boundary, and they all operate on the
key-value store — the *.kv.enc categories and the value-free catalog built
from them. Markdown documents are deliberately not reachable over MCP in this
release. Notes created with vbrain docs put, or brought in with the
Obsidian importer, have no discovery or resolve tool: an agent cannot list
them, search them, or read a field out of one.
This is a scope boundary, not an oversight, and it is narrower than the broader
"per-agent grants scoped by vault, collection, note, field, action and expiry"
described in docs/PRODUCT.md. The grant model implemented
here is file/key/action/expiry over key-value entries; note- and field-level
scoping for Markdown is not built, and adding discovery tools without it would
mean handing an agent a boundary the policy language cannot express. Use Mode 1
(vbrain docs ...) for Markdown, and reach for MCP when a value belongs in the
key-value store.
Encrypted sync protocol — Phase 6 foundation
Sync remains transport-independent: an append-only encrypted change log with per-device chains, causal parents and explicit conflict inspection. Exported JSON contains only keyed opaque IDs and AES-GCM ciphertext. Enrolled devices sign every new change with a separate Ed25519 key; the owner-signed encrypted registry pins device certificates and revocation sequence cutoffs. Owner-signed freshness checkpoints commit to the verified causal heads and change count. An authenticated self-hosted relay stores only opaque immutable ciphertext, with request, storage and object-count quotas.
Pass --sync-device <uuid> to a document or plugin command to capture its note,
canvas, attachment, plugin-package or plugin-policy mutation automatically. A
synchronized session also records an encrypted per-object application cursor,
so conflict-free remote changes can be replayed into the real vault exactly
once.
# Initialize enrollment and pin the returned authority fingerprint.
vbrain --experimental-trusted-sync sync devices init "Owner laptop" \
--device-id <device-id>
# On a securely bootstrapped second device, create a proof-of-possession request.
vbrain --experimental-trusted-sync sync devices request "Travel laptop" \
--device-id <second-device-id> > enrollment-request.json
# The owner approves it, then transfers the newer encrypted registry back.
vbrain --experimental-trusted-sync sync devices enroll enrollment-request.json
vbrain --experimental-trusted-sync sync devices export > device-registry.json
vbrain --experimental-trusted-sync sync devices import device-registry.json
# Removal is owner-signed at the last device sequence observed locally.
vbrain --experimental-trusted-sync sync devices revoke <second-device-id>
# Record revision 1 of one logical object.
vbrain --experimental-trusted-sync sync append <device-id> note <note-id> put \
--revision 1 --value '{"title":"Plan","body":"private"}'
# Exchange opaque envelopes through files/stdout.
vbrain --experimental-trusted-sync sync export > changes.json
vbrain --experimental-trusted-sync sync import changes.json
# Validate the complete DAG, list/inspect concurrent heads, then apply a clean one.
vbrain --experimental-trusted-sync sync verify
vbrain --experimental-trusted-sync sync conflicts
vbrain --experimental-trusted-sync sync resolve note <note-id>
vbrain --experimental-trusted-sync sync apply note <note-id>
# Resolve manually by naming one preserved head. Plugin-policy conflicts can
# instead use --safe, which only unions revocations and enables restricted mode.
vbrain --experimental-trusted-sync --sync-device <device-id> \
sync resolve note <note-id> --head <change-id>
vbrain --experimental-trusted-sync --sync-device <device-id> \
sync resolve vault plugin-policy --safe
# Example automatically captured edit.
vbrain --experimental-trusted-sync --sync-device <device-id> \
docs put Plans/Launch.md --body "Ready"
# Publish a known-good checkpoint and push it with the encrypted change log.
export VBRAIN_RELAY_TOKEN='<at-least-32-random-bytes>'
vbrain --experimental-trusted-sync sync checkpoint create
vbrain --experimental-trusted-sync sync relay push https://relay.example
# First contact pins fingerprints obtained through a trusted channel.
vbrain --experimental-trusted-sync --vault ./restored-vault \
sync relay pull https://relay.example \
--authority <owner-authority-sha256> \
--checkpoint <owner-checkpoint-sha256>Unresolved heads fail before live storage is touched. Plugin package bytes and
the fail-closed plugin policy are portable, while plugin storage and the local
enabled/disabled execution choice never enter the sync log; a received plugin
is always installed disabled. Attachment bytes travel outside the change
envelope: a change carries a manifest of content-addressed, AEAD-sealed 1 MiB
blobs, so an attachment of any size the vault accepts synchronizes and an
interrupted transfer resumes one chunk at a time. Independently witnessed
freshness remains Phase 6 work. The relay is not key escrow: first-device
recovery needs an encrypted vault backup containing the original
key-derivation metadata. A normal device clone must never carry another
device's documents/sync/identity directory; private identity keys are
local-only and absent from sync exports.
Revoking a device rotates the content key: the registry advances to a new epoch, a fresh random content key is wrapped to each remaining device's X25519 key, and the revoked device receives no wrap. Rotation is forward-only. A revoked device keeps the epoch keys it already held and can still decrypt every change written before the rotation; what it loses is everything written afterwards, including whatever the relay accumulates from then on. Recovering historical plaintext still only takes the passphrase and an encrypted vault backup — the passphrase remains the security boundary.
See the sync protocol design and relay operations guide.
The on-disk format is frozen at 1.0 — see docs/FORMAT-1.0.md.
The product itself remains pre-1.0 pending independent review.
The "fast find" layer
vbrain index rebuilds encrypted schema.enc — key names + descriptions, no values —
across the whole vault. vbrain search / the find_key MCP tool run a fuzzy
match over that small, safe file instead of decrypting and scanning every
vault file. This is the speed win over an Obsidian graph traversal: lookup
cost scales with the number of keys, not the size of your notes.
Threat model / honesty notes
- Encryption protects data at rest. Once
vbrain getorresolve_keydecrypts a value, it's plaintext in that process's memory / stdout — treat it like any other secret in a terminal. - The passphrase prompt is masked on a real terminal, and
vbrain unlock --remembercan hand the passphrase to Windows DPAPI, macOS Keychain, or libsecret on Linux. The macOS backend sends a base64 representation throughsecurity -ion stdin under a versioned service name, so the passphrase never appears in the child process argument vector; lookup remains compatible with older records. CI exercises a full store/lookup/delete cycle on every host OS. An OS credential store protects the secret from other users and other machines — not from code already running as you. VBRAIN_PASSPHRASEstill wins over both, which is what makes scripts and MCP work; an environment variable is visible to your own processes, so prefer the credential store for interactive use.- Nothing here stops a user (or a misconfigured agent with general filesystem
tools) from reading
vault/*.kv.encdirectly — they'll just get ciphertext, which is the actual protection. The real enforcement point is keeping an agent's filesystem access scoped away from the vault directory and routed through the MCP tools instead. - This is an MVP. It has not been audited. Don't put real medical or financial identifiers in it yet — validate the model with dummy data first.
- Rotation is forward-only. A revoked device keeps the epoch keys it already held and can still decrypt every change written before the rotation; what it loses is everything written afterwards, including whatever the relay accumulates from then on. Recovering historical plaintext still only takes the passphrase and an encrypted vault backup — the passphrase remains the security boundary.
Roadmap
Phases 0–5 are implemented. Phase 6 now has the encrypted immutable change log,
live note/canvas/attachment/plugin capture and application, owner-signed device
enrollment and removal with automatic epoch content-key rotation on revocation,
owner-signed freshness checkpoints, an authenticated opaque self-hosted relay,
an automated recovery drill, a frozen 1.0 on-disk format with committed
conformance fixtures, a native-packaged desktop sync helper with manual pairing,
revocation, relay push/pull and conflict resolution, and resumable
content-addressed transport for attachment blobs of any size the vault accepts.
The independent security audit remains open — see docs/AUDIT-SCOPE.md
for the audit readiness package.
Sync is desktop-to-desktop: Vault Brain stays local-first with optional
self-hosted sync, so the passphrase never leaves a machine the owner controls
and no hosted service is ever required.
Phase 7 replaced the passphrase-derived content key with a wrapped keyring, so
vbrain passphrase change re-wraps the keyset without re-encrypting a single
object, both cores read the new format, and the key-derivation cost can be
raised per vault. It also ships resumable re-key, recovery kits, controlled
lock recovery and the explicit vbrain rekey --rotate-identities migration.
Desktop passphrase and key-management controls are implemented in the current
desktop slice. The maintained checklist keeps the remaining external release,
platform and audit evidence open in docs/ROADMAP.md.
Personal-memory modules under src/memory/ are experimental and disabled by
default. No production import path enables them. Any future integration must use
the separately reviewed Phase 15 plan:
native broker, explicit pairing, an unlocked session, bounded secure queue, and
owner-controlled review/forget. It must preserve the new owner epoch and require
re-enrollment after identity rotation. No live MCP, desktop or account integration
is part of the current release.
License
MIT
