Skip to content

Fix(mcp): assistants stay inside the note folders - #23

Merged
adibhanna merged 1 commit into
mainfrom
claude/kind-saha-7b9fad
Oct 30, 2026
Merged

adibhanna merged 1 commit into
mainfrom
claude/kind-saha-7b9fad

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Phase 0 of the scoped MCP access design (#21): the hardening that has to land first, because a scope built on today's path checks is not a boundary.

What was wrong

zn mcp's note and folder tools only checked that a path stayed inside the vault. In a root-mode vault the inbox is the vault root, so .zennotes, the attachements folder and the other buckets were all "subfolders of the inbox", and the sources of moves and deletes were not classified at all. Probed over stdio JSON-RPC on scratch vaults (both MCP engines behaved the same):

Call Before After
move_note {path: ".zennotes/vault.json", targetFolder: "inbox", targetSubpath: "Projects"}, then read_note moved and read Note not in a known folder, vault.json in place
create_note {folder: "inbox", subpath: ".zennotes"} .zennotes/planted.md written Not a note folder: .zennotes
delete_folder subpath attachements / archive / Attachements (case variant, APFS) deleted Not a note folder: ...
rename_folder .zennotes -> Projects/internals, Projects -> .zennotes/x moved Not a note folder: ...
delete_folder {folder: "inbox", subpath: "x/.."} (root mode) whole vault deleted Cannot delete the top-level folder
delete_folder {folder: "archive", subpath: "x/.."} archive deleted refused
delete_note / archive_note / move_to_trash on .zennotes/vault.json deleted, or moved to archive/vault.md / trash/vault.md Note not in a known folder
move_note into subpath attachements, create_folder inbox "trash/x", write_note Projects/.hidden/x.md done refused
read_note Projects/notes.txt read Not a Markdown note: Projects/notes.txt
rename_folder Projects -> Work Tasks lists rewritten, icon and color left behind Tasks lists, folderIcons and folderColors rewritten

Ordinary create, move, archive, read, rename and delete of notes and folders behave as before.

What changed

All in internal/vault, so zn's own commands get the same rules as zn mcp:

  • notePath: every note a tool reads, edits, renames, moves, archives, trashes, duplicates, deletes or toggles a task in must classify as a note before it is touched: no dot directory at any depth, top segment classified through systemFolderPaths (never literal names), and in root mode not the internal dir, an attachments dir or another bucket. A case variant counts when it is the same directory on disk.
  • bucketDir: every (bucket, subpath) a create, move, rename or delete names must classify back into that bucket by the same rules. The folder tools refuse a subpath that resolves to the bucket root, with their existing messages.
  • ReadNote reads .md files only.
  • RenameFolder rewrites folderIcons and folderColors (<bucket>:<subpath> keys) along with the Tasks lists, as desktop main and the server do.

The desktop's bundled MCP engine gets the same rules in ZenNotes/zennotes on the 2.80.0 branch, plus a realpath step in its path resolution (it followed a symlink out of the vault, which SafeJoin here always refused). Both engines now answer all 23 probe cases identically.

Left for #21 Phase 1, unchanged here: symlinks that stay inside the vault, case-variant note paths (journal/DIARY.md), and absolute paths in missing-note errors.

Tests

  • New internal/vault/path_safety_test.go and internal/mcp/path_safety_test.go (the probe cases, through the vault API and through the real tools over the in-memory transport). All 7 fail on main and pass here.
  • go test -race ./..., go vet ./..., git diff --check, bash scripts/sandbox.sh --smoke: pass.

zn mcp's note and folder tools only checked that a path stayed inside the
vault. In a root-mode vault the inbox is the vault root, so .zennotes, the
attachements folder and the other buckets were all "subfolders of the
inbox", and the sources of moves and deletes were not classified at all. An
assistant could move .zennotes/vault.json into a note folder and read it,
plant notes in .zennotes, delete the attachments folder or the archive, move
.zennotes into the notes, archive, trash or delete vault.json, and with
delete_folder on "x/.." delete the whole vault: the subpath cleaned to the
bucket root and RemoveAll took it.

Every create, move, rename and delete of a note or folder now resolves both
ends the way the listings classify them, through the vault's
systemFolderPaths: no dot directory at any depth, and in root mode no
internal dir, attachments dir or other bucket reached through a subpath (a
case variant counts when it is the same directory on disk). The folder tools
refuse a subpath that resolves to the bucket root. read_note reads Markdown
notes only. A note outside the note folders is refused as before ("Note not
in a known folder"); a folder gets "Not a note folder: <subpath>". zn's own
commands share the vault code and get the same rules.

rename_folder now carries the folder's icon and color with it, as desktop
main and the server do, not only its Tasks entries.

The desktop's bundled MCP engine gets the same rules (ZenNotes/zennotes,
2.80.0). Symlinks that stay inside the vault, case-variant note paths and
absolute paths in errors are left for scoped access (#21).
@adibhanna
adibhanna merged commit 39110a2 into main Oct 30, 2026
5 checks passed
adibhanna added a commit to ZenNotes/zennotes that referenced this pull request Oct 30, 2026
The bundled CLI is the floor a desktop build guarantees, and 0.13.0 is
the newest release (ZenNotes/tui#24). It answers ZenNotes/tui#21: zn mcp
takes --allow-path, --deny-path, --read-only and --access and enforces
them on every tool, the same contract this app's bundled MCP server
follows since 969f50e. It also keeps every assistant out of hidden and
system folders through moves, renames and subpaths (ZenNotes/tui#23),
and puts a row zn base add makes into the picked rows of the view the
database opens on, as + Row does in the grid.

The pin is the release's terminal-release.json copied byte for byte
after it verified against the zn-release-1 key this app trusts
(cli-update-core.ts), naming source commit cae76aff (the v0.13.0 tag),
integration protocol 1, and four archives that match both the manifest
and checksums.txt. Staged through `npm run terminal:stage`; the native
darwin-arm64 binary reports zn v0.13.0. The in-app manual's CLI card
says 0.13.0 and what it adds.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant