diff --git a/.gitattributes b/.gitattributes index 16e8d65..5b76391 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ # Windows runners whose Git defaults to CRLF conversion. internal/remote/testdata/** text eol=lf internal/vault/testdata/** text eol=lf +internal/mcp/testdata/** text eol=lf diff --git a/README.md b/README.md index 6821531..c88a055 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ vault too. core of everything. Prefer a plain editor? Turn Vim off. Files, comments, bulk actions, editable settings, themes, and per-vault sessions are included. - **`zn mcp`.** The same tools the desktop's MCP server offers, for Claude - Code, Codex and any MCP client. + Code, Codex and any MCP client, on the whole vault or only the folders you + share, read-only if you like. - **Local and self-hosted workspaces.** Keep several vaults and server connections, switch between them, and use the same note commands against either backend. Remote changes arrive over the live feed with polling fallback. @@ -244,7 +245,10 @@ zn base pages "Reading list" Use the stable ID returned by `task list` with `task toggle `. Comment replies and resolve/reopen operations take a thread ID. `base add` and `base set` accept repeated `--set Field=Value` flags for existing fields; -`base add --body` or `--page` also creates a record page. `base convert` +`base add --body` or `--page` also creates a record page. When the view a +database opens on shows only picked rows, `base add` adds the new row to +them, as + Row does in the app, so the view still shows it; other views keep +their picks, and `--json` names the view in `pickedInView`. `base convert` turns a loose CSV into a `.base` folder, and `base pages` gives every record that has no page its record page, so a database converted from a CSV can be linked right away. A page file already in the database folder @@ -951,6 +955,67 @@ zn connect https://notes.example.com --no-default `--no-default` saves the token without making the server zn's own default, so zn keeps following the app. +### Scoped access + +By default an MCP client sees the whole vault. Flags on `zn mcp` narrow what it +can see and change, and the server enforces them on every tool (listings, +search, tags, tasks, backlinks, assets, comments, error messages), so hidden +notes cannot be found indirectly: + +```bash +zn mcp --allow-path Projects --allow-path Areas/Health --deny-path Projects/Clients --read-only +``` + +```json +{ "mcpServers": { "zennotes": { "command": "zn", "args": ["mcp", "--allow-path", "Projects", "--read-only"] } } } +``` + +| Flag | Meaning | +|------|---------| +| `--allow-path ` | Share only this folder or note, and what is inside it. Repeat for more. Without it, the whole vault is shared. | +| `--deny-path ` | Hide this folder or note even inside an allowed one. Repeat for more. | +| `--read-only` | Read tools only: no tool that creates, edits, moves or deletes is offered. Same as `--access read`. | +| `--access read\|comment\|write` | `comment` adds the comment tools to the read tools; `write` (the default) is everything the paths allow. | + +Paths are vault-relative, as the tools report them (`Projects` in a root-mode +vault, `inbox/Projects` in an inbox-mode one, remapped system folders under +their own names), and a leading `/` is fine. A path matches the folder and +everything below it, never a sibling that merely starts with the same letters. + +What the client gets: + +- A hidden note answers exactly like a missing one (`Note not found: `). + Paths through a symlink, case variants of a hidden folder and the internal + `.zennotes` directory do not exist for it, and its errors and `vault_info` + never show the vault's absolute location. +- Tools it cannot use are not listed at all. `empty_trash` is never offered + once paths are restricted, since it deletes every trashed note. +- Notes can be created, moved and restored only inside allowed folders, and + never through a symbolic link (which the listings skip, so it would otherwise + look like a new folder). Archiving and trashing a visible note are allowed + even when the archive or trash is not shared; the note then leaves what the + client sees. +- Renaming a note rewrites `[[links]]` only in notes the client can see. On a + ZenNotes server, which rewrites links in every note itself, a scoped client + cannot rename notes. +- A folder is renamed or deleted as a whole only when nothing inside it is + hidden. +- A `--deny-path` that matches nothing in the vault (renamed outside ZenNotes, + or meant for another vault) shares nothing at all until it is fixed, so a + rename can never quietly expose what it hid. The MCP server's stderr, which + clients keep in their logs, names the entry. + +The desktop app's bundled MCP server takes the same flags and gives the same +answers (the shared fixture `internal/mcp/testdata/mcp-scope.json` holds both to +it), with one difference: it never rewrites inbound links on a rename, scoped or +not, so a rename through it leaves every other note as it was. + +The scope governs the ZenNotes tools only. An assistant that can also run +commands or read files (Claude Code, Codex, OpenCode) can still read the vault +from disk. The design, including what comes next (per-vault profiles chosen +from the desktop app), is in +[`docs/ideas/mcp-scoped-access.md`](docs/ideas/mcp-scoped-access.md). + ## Layout of the code ``` diff --git a/docs/desktop-integration.md b/docs/desktop-integration.md index 86ffced..3c7ce76 100644 --- a/docs/desktop-integration.md +++ b/docs/desktop-integration.md @@ -79,6 +79,17 @@ again before every tool call and follows a desktop switch once `vault_info` confirms it. Until then every other tool stops with a message naming the old and new workspace, so a switch never redirects in-flight edits. +`zn mcp --allow-path`, `--deny-path`, `--read-only` and `--access` (ZenNotes/tui#21) +apply to whichever workspace the server follows. Entries are vault-relative, so a +switch applies the same paths to the new vault; a `--deny-path` the new vault does +not have shares nothing there. The desktop's bundled MCP server (`out/main/mcp.js` +and the desktop's own `zn mcp`) takes the same flags with the same rules, from +`packages/shared-domain/src/mcp-scope.ts` in ZenNotes/zennotes, and both engines +answer the shared fixture `internal/mcp/testdata/mcp-scope.json` the same way, so a +client is scoped whichever runtime Settings → MCP installed for it. Settings → MCP +shows a client with hand-added flags as needing an update; updating it there drops +them. + ## Creation dates Linux reads filesystem birth time using `statx` where supported; ctime is only a diff --git a/docs/ideas/mcp-scoped-access.md b/docs/ideas/mcp-scoped-access.md new file mode 100644 index 0000000..97c25fa --- /dev/null +++ b/docs/ideas/mcp-scoped-access.md @@ -0,0 +1,651 @@ +# Scoped access for the MCP servers (ZenNotes/tui#21) + +> Status: design accepted 2026-10-29 (section 12). zn 0.13.0 (2026-10-30) ships Phase 0 +> (ZenNotes/tui#23) and Phase 1a (Go, flags only) with the link fix and the shared contract +> test; section 13 records where 1a differs from this design. Phase 1b (the desktop's bundled +> server, the shared fixture) is 7be9e993 in ZenNotes/zennotes, landing in desktop 2.80.0; +> section 14 records it and a fix it made to both engines. Phases 2 to 4 are not started. +> Written 2026-10-29 for +> [ZenNotes/tui#21](https://github.com/ZenNotes/tui/issues/21) (lewxdev). Covers both MCP +> engines: the Go `zn mcp` in this repo and the TypeScript server the desktop app bundles +> (`apps/desktop/src/mcp` in ZenNotes/zennotes). Decisions are at the end. + +## Summary + +- **One idea, both engines.** A *scope* is an access level (`read`, `comment`, `write`) plus + folder lists (`allow`, `deny`). It is enforced in the server, in a wrapper around the + vault backend that every tool already goes through, so no tool can skip it. +- **Default-deny.** A non-empty `allow` list shows only what it names; everything else does + not exist for the client. `deny` wins inside allowed folders. Errors for hidden paths are + byte-identical to errors for missing ones. +- **How a client gets one.** Ad hoc flags (`zn mcp --allow-path Projects --read-only`, the + issue's shape) or a named profile (`zn mcp --profile chatgpt`) defined per vault in + `.zennotes/mcp.json`. Settings → MCP writes `--profile` into each client's launch config. + Flags and a profile combine by intersection; nothing can widen what the other allows. +- **Fail closed everywhere.** Unknown profile, unreadable file, a hidden folder that no + longer exists, a server too old to serve profiles: the client sees nothing, with a message + that says how to fix it. +- **Fix the floor first (Phase 0).** Probing both engines found that today's tools can move + `.zennotes/vault.json` into a note folder and read it, plant notes inside `.zennotes/`, + delete the Archive or attachments folder through an inbox subpath, and (desktop engine + only) follow a symlink out of the vault. A scope built on top of that is not a boundary. + These are worth fixing on their own, before #21. + +## 1. The ask + +From the issue: `zn mcp` exposes the whole selected vault. The reporter wants path-based +restrictions (`--allow-path /projects`), a read-only mode with no create/modify/delete tools, +default-deny once restrictions are set, the same restrictions on every tool (search, listing, +note references) so excluded content cannot be discovered indirectly, and enforcement in the +server rather than in client permissions. + +Users reach two servers with the same 34 tools: the Go engine (`zn mcp`, this repo, +`internal/mcp`) and the desktop's bundled engine (`out/main/mcp.js` run with +`ELECTRON_RUN_AS_NODE`, or the desktop's own TS `zn mcp`). Settings → MCP → Install picks one +per client (`getMcpServerRuntime` in `apps/desktop/src/main/mcp-integrations.ts`). A design +that only covers one engine leaves the other as an unscoped side door, so this covers both +with one config shape. + +## 2. What the servers do today + +### 2.1 Call path + +Both engines have the same shape: + +``` +client ── tools/call ──▶ dispatcher ──▶ VaultSession.backendFor() (re-resolves the vault every call, + │ blocks after a switch until vault_info) + ▼ + handler(args, backend) ──▶ Backend (Local | Remote) + │ + runRecording (agent edit review) for text edits +``` + +- Go: `internal/mcp/server.go` (`tools()`, `Run`), `session.go`, `agentedits.go`; + `internal/backend` (`Local`, `Remote`); `internal/vault` (path safety in `paths.go`). +- TS: `apps/desktop/src/mcp/server.ts` (`TOOLS`, `VaultSession`, `runMcpServer`), + `vault-ops.ts`, `apps/desktop/src/cli/backend.ts` (`VaultBackend`, local and remote). + +Every handler receives a backend and does its own filtering on top of `ListNotes`, +`SearchText`, `ScanTasks` and friends. That is the seam: a scoped backend decorator that +implements the same interface is the one place enforcement can live without touching 34 +handlers, plus a gate in the dispatcher for the access level. + +### 2.2 The 34 tools + +Reads: `vault_info`, `list_notes`, `list_folders`, `list_assets`, `read_note`, `search_text`, +`search_by_title`, `search_by_tag`, `list_tags`, `backlinks`, `list_tasks`, `list_comments`. +Comments: `add_comment`, `reply_to_comment`, `resolve_comment`. +Note writes: `write_note`, `append_to_note`, `prepend_to_note`, `insert_at_line`, +`replace_in_note`, `toggle_task`, `create_note`, `rename_note`, `move_note`, `duplicate_note`, +`archive_note`, `unarchive_note`, `move_to_trash`, `restore_from_trash`, `delete_note`. +Folder writes: `create_folder`, `rename_folder`, `delete_folder`, `empty_trash`. + +Neither engine sets MCP tool annotations (`readOnlyHint`, `destructiveHint`) today. + +### 2.3 Verified gaps (probed 2026-10-29) + +Both engines were driven over stdio JSON-RPC against scratch vaults in root mode: the Go +binary built from this repo's `main` (4c4237b) and the desktop's built +`apps/desktop/out/main/mcp.js` (Oct 26 build; `vault-ops.ts` last changed Oct 26). Scripts and +transcripts are in the shared notes folder (`mcp-scoped-access/`). + +| # | Call | Go `zn mcp` | Desktop engine | +|---|------|-------------|----------------| +| G1 | `move_note {path: ".zennotes/vault.json", targetFolder: "inbox", targetSubpath: "Projects"}` then `read_note` | moved and readable | moved and readable | +| G2 | `create_note {folder: "inbox", subpath: ".zennotes", ...}` | `.zennotes/planted.md` written | same | +| G3 | `delete_folder {folder: "inbox", subpath: "attachements"}` / `subpath: "archive"` | both deleted | both deleted | +| G4 | `rename_folder {folder: "inbox", oldSubpath: ".zennotes", newSubpath: "Projects/internals"}` | internal dir moved into notes | same | +| G5 | `read_note` through `Projects/link -> ` | refused (`SafeJoin` resolves links) | **file outside the vault returned** (`resolveSafe` is lexical) | +| G6 | `read_note` through `Projects/inner -> ../Journal` | Journal note returned | Journal note returned | +| G7 | `read_note {path: "journal/DIARY.md"}` on APFS | `Journal/Diary.md` returned | (same FS behavior) | +| G8 | `read_note` of a missing note | error carries the absolute path | same | + +Causes: `MoveNote`/`moveNote` never classify the *source*; subpaths are only checked against +escaping the bucket root, and in root mode the inbox bucket root is the vault root, so every +hidden and system directory is a "subpath of inbox"; the TS `resolveSafe` has no realpath step. +Also in passing: the desktop engine's `renameFolder` does not rewrite vault.json's +path-keyed lists (`tasks.excludedFolders`, folder icons and colors), which desktop main, +app-core, znserver and Go zn all do; and `read_note` reads any file in a note folder, not +only `.md`. + +G1 to G4 matter for #21 directly: if profiles live in the vault, a write-capable client must +not be able to reach the file that defines them. G5 to G7 are the classic scope bypasses (the +reference filesystem MCP server's CVE-2025-53109 was this class) and dictate how the scope +check canonicalizes paths. G8 is the existence oracle a scope must close. + +## 3. Threat model + +Protects against: +- An MCP-only assistant (Claude Desktop chat, ChatGPT through a bridge, any client without a + shell) reading, listing, searching or inferring notes outside what the user shared. +- Such an assistant writing outside its scope, or writing at all when read-only. +- Prompt injection that steers a scoped assistant toward hidden content: the content is not + reachable, whatever the model is told. +- A scope silently applying to the wrong vault after the app switches vaults. + +Does not protect against: +- Clients that also have a shell or filesystem tools (Claude Code, Codex, OpenCode). A scope + governs MCP tools only; such a client can read the files directly. The docs must say so + plainly, and Settings should say it on those client rows. +- Anything running as the user (other processes, a full-access MCP client, sync tools) or + hard links the user created. +- Timing side channels. + +## 4. Design + +### 4.1 Scope model + +A **scope** is: + +| Field | Meaning | +|-------|---------| +| `access` | `read` (read tools), `comment` (read + the three comment tools), `write` (everything the paths allow). A profile that omits it gets `read`. | +| `allow` | Vault-relative folder or note paths. Empty means the whole vault. | +| `deny` | Vault-relative folder or note paths, hidden even inside `allow`. | + +A path is **visible** when all of these hold: + +1. It is a note (`.md`) or folder inside a note bucket, classified through the vault's + `systemFolderPaths` (`FolderForRelativePath` / `folderOf`). `.zennotes`, dot entries, the + attachments directory and `.base` internals are never visible, whatever the lists say. +2. `allow` is empty, or the path equals an `allow` entry or lies under one (segment prefix: + `Projects` covers `Projects/x.md`, never `Projects2/x.md`). This is the rule + `isPathExcludedFromTasks` already uses for the Tasks folder lists (#458, #962). +3. It is not equal to or under a `deny` entry. Deny additionally matches after NFC and case + folding, on purpose: on a case-insensitive volume a near-miss must over-hide, never leak. +4. Every component on the way is a real directory, not a symlink (see 4.4). + +**Structural ancestors.** The folders above an `allow` entry (`Areas` for `Areas/Health`) +are listed by name so the tree makes sense, but nothing directly inside them is visible and +nothing can be created there. Their names are part of the entry the user wrote, so they leak +nothing. + +**Paths are physical,** exactly as the tools return them (`inbox/Projects` in inbox mode, +`Projects` in root mode, `99 Archive/Projects` when Archive is remapped). This is the +tasks-lists rule ("vault-relative directory paths exactly as they exist on disk, so remapped +system folders need no translation"). The scope evaluator never joins a literal folder name; +classification goes through `systemFolderPaths` like everything else. + +**System buckets.** Archive and Trash keep the note's subpath (`inbox/Projects/X.md` archives +to `archive/Projects/X.md`), so a user who wants archived project notes visible adds +`archive/Projects`, and `trash/Projects` for trashed ones. Nothing is implied. + +**No globs in v1.** Folder and note entries cover the issue, match the app's model, stay +rename-rewritable, and keep Go and TS matching trivially identical. Globs are an open question. + +### 4.2 Where scopes come from + +``` + ┌───────────── client launch config (args/env), written by Settings → MCP ─────────────┐ + │ zn mcp --profile chatgpt [--allow-path P]... [--deny-path P]... [--read-only|--access A] │ + └──────────────────────────────────────────────────────────────────────────────────────┘ + │ profile name │ inline restrictions + ▼ ▼ + /.zennotes/mcp.json ── profiles.chatgpt ──▶ effective scope = profile ∧ flags + (per vault; remote: served by znserver) +``` + +- **No `--profile` and no flags:** unscoped, exactly today's behavior. Nothing changes for + anyone who does nothing. +- **Flags only** (`--allow-path`, `--deny-path`, `--read-only`, `--access`): an inline scope, + the issue's exact ask, works against any vault and any server version with no stored config. + A leading slash is accepted and stripped (`/projects` → `projects`). +- **`--profile NAME`** (or `ZENNOTES_MCP_PROFILE`, for the legacy `mcp.js` entry and clients + that only pass env): the profile of that name in the *current* vault's `.zennotes/mcp.json`. +- **Both:** a path is visible only if every scope says so, and access is the lowest of them. + Restrictions compose by intersection; no input can widen another. That is also how + `--read-only` on top of a write profile behaves. +- **Never from tool arguments.** No tool takes a scope or profile; `list_notes`' `folder` and + `subpath` only narrow inside it. Same rule as the bridge trust model: the authority comes + from the process's own state, never from the caller. + +**Why a file in the vault.** Entries are vault-relative, so they belong to a vault, not to a +user: the MCP follows the app across vaults (#688, #830), and a `Projects` allowlist meant +for the personal vault must not quietly apply to the work vault. Per-vault profiles make the +switch safe by construction: the session already stops after a switch until `vault_info` +confirms (`session.go` / `VaultSession`), and the new vault either defines `chatgpt` or the +client sees nothing. They also travel with the vault to other machines and to a self-hosted +server, which a future server-side `/mcp` can enforce natively. + +**Why its own file, not vault.json.** znserver's `VaultSettings` is a typed struct, and the +round-trip rule (#446, #379) says any key not modeled there is dropped when a web client +saves settings. Desktop main, the phones' vault-core and older versions of all of them write +vault.json too. A `deny` list that an older client drops fails *open*. `.zennotes/mcp.json` +is written only by MCP-aware code (Settings → MCP, `zn mcp profile`), so no settings save +elsewhere can lose it, and no phone app needs a change. + +**Why not config.toml.** config.toml is the user's portable prefs and stays that +(`[mcp] review_agent_edits` already lives there). Vault-relative path lists in a user-level +file would need per-vault keys that do not survive moving a vault between machines, and no +host could rewrite them on a folder rename (znserver cannot touch it). Ad hoc flags already +cover "a scope not tied to a vault". + +**Rejected:** selecting a profile from the client's `initialize` name (client-asserted, +spoofable, and unrelated to what the user configured); an implicit `default` profile for +clients that pass none (a lost or unsynced `mcp.json` would then silently widen every +client); MCP roots as the mechanism (see 6). + +### 4.3 Config shape + +`.zennotes/mcp.json` (pretty-printed, written atomically like vault.json): + +```json +{ + "version": 1, + "profiles": { + "chatgpt": { + "label": "ChatGPT", + "access": "read", + "allow": ["Projects", "Areas/Health/Plan.md"], + "deny": ["Projects/Clients"] + }, + "reviewer": { + "label": "Claude (review only)", + "access": "comment", + "deny": ["Journal"] + } + } +} +``` + +Rules, normative in the shared module (5.1): +- Profile names: `[a-z0-9][a-z0-9_-]{0,31}`, compared lowercase. +- Entries validated by `normalizeTasksExcludedFolder` (backslashes to slashes, trimmed + segments, no `.`/`..`, at most 512 characters), deduplicated, order kept. An entry with a + dot-prefixed segment (`.zennotes`, any hidden folder) is invalid. The attachments directory + may be named; it only ever matches assets. +- An unknown `version`, unparseable JSON, or a profile that fails validation: that profile + denies everything. A typo never becomes "whole vault". +- `access` other than the three values: `read`. + +Flags on both `zn mcp` engines (and argv for `mcp.js`): + +```bash +zn mcp --profile chatgpt +zn mcp --allow-path Projects --allow-path Areas/Health --deny-path Projects/Clients --read-only +zn mcp --profile reviewer --access read # narrows the profile further +``` + +`--read-only` is an alias for `--access read`. Repeated flags accumulate. + +### 4.4 Canonical paths (traversal, symlinks, case) + +Every path a tool receives is turned into a canonical vault path *before* the scope check, +and the check runs on that: + +1. Reject NUL, drive letters, and any `.` or `..` segment outright (no resolving); turn `\` + into `/`; strip leading `/` and `./`. +2. Look the path up in the backend's own listings (`ListNotes`, `ListFolders`), local and + remote alike: exact path first, else the single listed path equal under case and accent + folding, so the vault's spelling is what the scope checks and what the backend opens. The + listings walk real directories and regular files only (`walkNotes` and `listNotes` read + `Dirent` types; the server's walker does the same), so a path through a symlink, a case + variant of a hidden folder, a hidden directory or an unexpanded Windows 8.3 name simply + has no match, and G5 to G7 close. A path the client was never shown does not exist for it. + (The design first had a per-segment `Lstat` walk for local vaults; one rule for both + backends is simpler and needs no platform cases. The cost is a listing per call, which + most tools already pay.) +3. Creates (`create_note`, `create_folder`, move and restore destinations): existing folders + keep their listed spelling, new segments are created as given; a segment ending in `.base` + (a database's insides), and in root mode a first segment that names a system, asset or + internal directory under any case (`vault.IsReservedRootName`), are refused; the result + must still classify into the bucket asked for and lie inside an `allow` entry. +4. Match the canonical path against the entries, which are themselves spelled the vault's way + once per call (so `--allow-path /projects` finds `Projects/`). + +### 4.5 Fail-closed rules + +| Situation | Behavior | +|-----------|----------| +| `--profile X` and the vault has no `mcp.json`, or no `X` | Every tool but `vault_info` errors: "This vault has no MCP profile named X, so nothing is shared. Add it in ZenNotes Settings > MCP > Assistant access." | +| `mcp.json` unreadable or invalid | Same, naming the file problem, never its contents. | +| A `deny` entry no longer exists on disk | The profile denies everything: "This profile hides a folder that no longer exists (renamed or deleted outside ZenNotes). Open Settings > MCP to update it." The folder is not named. Renames through ZenNotes rewrite entries (5.3), so this only fires after an outside rename or an old client's rename, which is exactly when a deny list would otherwise fail open. | +| An `allow` entry no longer exists | It matches nothing (already closed); `vault_info` says one entry is missing. | +| Remote server without the profiles endpoint | `--profile` clients see nothing: "This ZenNotes server does not serve MCP profiles (needs server X.Y)." Flags-only scopes still work. | +| Vault switch | Unchanged session rule, then the new vault's profile (or nothing). | + +## 5. Behavior of every tool under a scope + +"Not found" means the uniform error `Note not found: ` (or +`Folder not found: ...`), the same bytes whether the path is hidden or does not exist, with no +absolute path (closes G8). A tool above the access level is not listed and, if called anyway, +answers "This assistant can only read this vault (profile chatgpt)." without touching a path. + +| Tool | Needs | Under a scope | +|------|-------|---------------| +| `vault_info` | read | Adds `access: {profile, level, allow, partial, missingEntries}`; `subfolders` filtered to visible folders plus structural ancestors; `deny` entries are never named; omits `vaultRoot`, `inboxAbsolutePath` and the remote `vaultPath` (they name the user's home directory and add nothing for an MCP-only client). | +| `list_notes` | read | Visible notes only; `folder`, `subpath`, `tag`, `wikilinkTo`, `updatedSinceMs` narrow inside the scope; `limit` applies after it. `folder: "trash"` returns visible trashed notes only. | +| `list_folders` | read | Visible folders plus structural ancestors; denied subtrees absent. | +| `list_assets` | read | Assets inside visible folders. Phase 3: plus assets a visible note embeds or links (via the asset-link parsers), never the rest of the shared attachments folder. | +| `read_note` | read | Visible `.md` note, else not found. | +| `search_text` | read | Hidden subtrees are skipped during the walk; `limit` counts visible matches only (today the walk stops at `limit`, and the remote backend truncates before returning, so filtering after would leak by short results). Snippets come only from visible notes. | +| `search_by_title`, `search_by_tag` | read | Over visible notes. | +| `list_tags` | read | Counted over visible notes; a tag used only in hidden notes does not appear. | +| `backlinks` | read | Target must be visible; results filtered to visible notes. | +| `list_tasks` | read | Tasks of visible notes; `includeExcluded` widens the Tasks exclusions, never the scope. | +| `list_comments` | read | Visible note only. | +| `add_comment`, `reply_to_comment`, `resolve_comment` | comment | Visible note only. | +| `toggle_task` | write | The note in the id (`path#index`) must be visible. | +| `write_note`, `append_to_note`, `prepend_to_note`, `insert_at_line`, `replace_in_note` | write | Visible note only; agent edit review unchanged. | +| `create_note`, `create_folder` | write | Destination inside an `allow` entry (not only an ancestor), not denied, never a hidden or system directory reached through a subpath (closes G2 for scoped clients; Phase 0 closes it for all). An error at an ancestor names where creating is possible: "You can create notes only in: Projects, Areas/Health." | +| `rename_note` | write | Source visible, new path visible. Inbound links are rewritten in visible notes only (decision 6). A server rewrites links in every note itself, so on a remote vault a path-restricted client cannot rename. | +| `move_note` | write | Source and destination visible. | +| `duplicate_note` | write | Source visible; the copy lands beside it. | +| `archive_note`, `move_to_trash` | write | Source visible. The destination may be hidden (a lifecycle exit: the client loses sight of the note, the user can undo in the app). Decision 5. | +| `unarchive_note`, `restore_from_trash` | write | Source visible (needs `archive/...` or `trash/...` allowed) and destination visible. | +| `delete_note` | write | Visible note; `confirm` as today. | +| `rename_folder`, `delete_folder` | write | Source and destination inside `allow` entries and no `deny` entry inside the subtree, else refused with one generic message. Decision 7. | +| `empty_trash` | write, unscoped only | Not listed under any `allow` or `deny`: it acts on every trashed note, visible or not. | + +Every tool also gets MCP annotations from the same table (`readOnlyHint` for reads, +`destructiveHint` for `delete_*`, `empty_trash`, `write_note`), so clients that confirm +destructive calls can, scoped or not. + +When a scope is active, the server instructions gain one paragraph: "This server shows part +of the user's vault. Paths and notes you are not shown do not exist for you; do not guess at +them, and treat links that do not resolve as pointing outside what you can see. Call +`vault_info` to see what you can reach." + +## 6. Leak analysis + +| Channel | Today | Under a scope | What remains | +|---------|-------|---------------|--------------| +| Listings (`list_notes`, `list_folders`, `vault_info.subfolders`) | whole vault | visible + structural ancestors | Ancestor names, which the entries contain anyway. | +| `search_text` results, counts, snippets | whole vault; stops at `limit` | filtered during the walk, limit after | None. | +| `search_by_title` / `search_by_tag` / `list_tags` counts | whole vault | visible only | None. | +| `list_tasks`, task ids | whole vault | visible only | None. | +| Backlinks into a visible note | from any note | visible linkers only | None. | +| Outbound links of a visible note (`wikilinks`, body) | n/a | unchanged | The *text* of a link to a hidden note, which is the visible note's own content. Its target never resolves for the client. | +| Wikilink resolution (`search_by_title`, `list_notes(wikilinkTo)`, `link` deep links) | whole vault | visible only | Hidden targets look unresolved. | +| Assets (`list_assets`) | every non-note file under the root | visible folders (later: referenced by visible notes) | Names and sizes of referenced assets, which the note already names. | +| Comments | any note | visible notes | Text a comment quotes, which is from the visible note. | +| Templates, databases, settings | no MCP tool | unchanged | A future tool must take the scope like the rest. | +| Error text (G8) | absolute path, distinct "missing" vs "not in a folder" | one uniform message, no absolute path | None. | +| Name collisions (`uniqueTitle` picks "Plan 2"; rename conflict error) | n/a | unchanged | One bit: a hidden note with that exact name exists in the same visible folder. Only possible with a `deny` note entry inside an allowed folder, only for a guessed name. Documented. | +| Folder-op refusals (`rename_folder`, `delete_folder`) | n/a | one generic refusal | One bit: something hidden lies inside. Documented; decision 7. | +| Hidden areas (`.zennotes`, attachments) via move/create/folder ops (G1 to G4) | reachable | never visible | None once Phase 0 lands for unscoped clients too. | +| Symlinks (G5, G6), case variants (G7), Unicode normalization, 8.3 names | followed | canonical walk refuses | None known. | +| Vault switch (#688 follow-app) | scope-less | per-vault profile or nothing | None. | +| Absolute paths in `vault_info` | shown | omitted | None. | +| Custom MCP instructions (user-written file) | sent | sent | Whatever the user wrote in it. | +| Link rewrite on rename | Go zn and znserver rewrite every note; the desktop engine none | decision 6 | If kept: the new title is written into hidden notes; nothing flows back to the client. | +| Remote token | in the MCP process env or credentials.toml | unchanged | The client never sees it; it cannot call the server API around the scope. | + +## 7. Sharing between the desktop engine and zn + +### 7.1 What is synced today + +From the zennotes CLAUDE.md and the code: + +| Logic | Copies | +|-------|--------| +| System-folder path rules | `packages/shared-domain/src/system-folder-paths.ts` (normative), a copy in `apps/desktop/src/mcp/vault-ops.ts`, znserver `internal/vault/types.go`, and Go zn `internal/vault/settings.go` | +| `stripCodeContent` | 5 in the monorepo (desktop main, MCP, app-core tags, app-core wikilinks, Go server) plus the iPhone vault-core | +| Task-line parser | 4 (editor, CLI, MCP, Go) | +| Tasks folder lists and their rename rewrite | `shared-domain/tasks-excluded-folders.ts` (imported by desktop main, the MCP, the iPhone bridge), znserver `internal/vault/tasks_exclude.go`, Go zn `internal/vault/settings.go` | +| MCP tool table, schemas and descriptions | hand-kept parity between `server.ts` and `server.go` | +| MCP instructions, client display names, token hint, agent-edit recorder | verbatim mirrors (`instructions.go` `DefaultInstructions`, `agentedits.go`) | + +### 7.2 What this adds, and where + +- **Normative module:** `packages/shared-domain/src/mcp-scope.ts`, pure TS: parse and + validate `mcp.json` and flags, `effectiveScope(...)`, `isVisible(canonicalPath, kind)`, + `isStructuralAncestor`, `rewriteMcpScopeForRename(oldRel, newRel)`, and `MCP_TOOL_ACCESS` + (tool → `read | comment | write | unscopedWrite` plus annotations). The desktop MCP may import + `@shared` (bundled into `out/main/chunks`), so it imports this instead of copying, as it + already does for the tasks lists. Settings, desktop main (rename rewrite) and web import it. +- **Go mirrors (one new synced copy family, three places):** Go zn `internal/mcp/scope.go` + (evaluator, canonical walk, decorator) and the rename rewrite next to the tasks-lists one in + `internal/vault`; znserver `internal/vault` (rename rewrite now, native enforcement later). +- **Decorator, one per engine:** TS `apps/desktop/src/mcp/scoped-backend.ts` implements + `VaultBackend`; Go `internal/mcp/scoped.go` implements `backend.Backend`. The dispatcher in + each wraps the session's backend when a scope is active and checks `MCP_TOOL_ACCESS` before + the handler runs. Handlers that compute over `ListNotes` (`list_tags`, `search_by_*`) become + correct for free; `SearchText` gets a "no limit, filter, then cut" path because Go's + `SearchText` treats `limit <= 0` as 80 and the remote one truncates. +- **Contract fixture:** `mcp-scope.json` exported from zennotes (vault trees including symlinks, + case variants, remapped system folders, root and inbox mode; scopes; tool calls; expected + results and error bytes), consumed in this repo under `internal/mcp/testdata/` with a + `.source.json` hash, per `docs/shared-contracts.md`. Neither implementation regenerates + expectations from itself. +- **Tool table parity check:** a test in each repo that the tool list in `MCP_TOOL_ACCESS` + (and its Go mirror) equals the registered tools, so a new tool cannot ship unclassified. + An unclassified tool is treated as `unscopedWrite` (not listed under any scope). + +### 7.3 Rename rewriting + +Folder renames through ZenNotes must repoint `allow` and `deny` entries, like the tasks lists: +desktop main (`vault.ts` rename path), app-core store, znserver `RenameFolder`, Go zn +`RenameFolder`, and both MCP engines' `rename_folder` (the desktop engine skips the tasks lists +today; fix both together). Folder deletes through ZenNotes drop entries under the deleted +folder. Renames outside ZenNotes are caught by the stale-deny rule (4.5). + +## 8. Remote and self-hosted vaults + +- The decorator wraps the `Remote` backend the same way; canonical paths come from the + server's listings (4.4 step 3). Flags-only scopes need no server change. +- Profiles for a remote vault live in that vault's `.zennotes/mcp.json` on the server. The + server needs `GET /api/vault/mcp-profiles` (and `PUT` for Settings), returning the validated + file; `.zennotes` stays unreachable through the note APIs. An older server answers 404 and + `--profile` clients see nothing (4.5). +- Token handling (#830, Settings → MCP → Server token) is unchanged: the MCP process holds a + full token, which the client never sees. +- Later, with the parked remote connector (znserver `/mcp`, OAuth): the server enforces the + same profiles natively, and a scoped server token could bind a profile to the token so the + HTTP API itself refuses out-of-scope paths. Not needed for #21. + +## 9. UI and docs + +### Desktop Settings → MCP + +- New **Assistant access** section: the vault's profiles as rows (label, access, "Projects, + Areas/Health" summary, "shares 213 of 1,402 notes"). New / Edit / Delete. +- Profile editor: access segmented control (Read / Comment / Edit); "Can see": Whole vault or + Only these folders, built like `TasksFolderListRow` (the Tasks folders editor, #458, #962); + "Always hidden" with the same editor; a live preview list of what the profile shows, + computed by the shared evaluator, so the user checks it before an assistant does. +- Each client row (`McpClientRow`) gains a **Profile** select (Full access, or a profile). + Install writes `--profile ` into the client's args for both runtimes + (`['mcp', '--profile', name]` for a managed zn, `[entry, '--profile', name]` for + Electron + `mcp.js`); the existing `upToDate` comparison already covers args. Rows for + clients with a shell (Claude Code, Codex, OpenCode) carry one line: "This assistant can also + read files directly; a profile limits its ZenNotes tools only." +- Stale entries show inline ("Journal no longer exists") with Remove / Pick folder. +- Settings search items for each new control (`mcp-assistant-access`, `mcp-client-profile`). + +### Keyboard + +- Profile rows and folder lists navigate with j/k and Enter when Vim is on, arrows otherwise + (the Vim-off rule: no single-key list shortcuts). +- Sidebar folder context menu: "Share with assistant ▸ " and "Hide from assistant ▸ + ". This is how the Tasks folder lists already work: the folder menu is the + primary way in, the Settings list is where entries are reviewed and removed. +- Palette command `mcp.assistantAccess` ("Assistant access…") opens the section. +- Vim binding: proposed under note actions, `Space l h`, "share or hide this note's folder for + an assistant profile" (opens a small profile picker). Final key is Adib's call. + +### Terminal (this repo) + +- `zn mcp profile list | show | explain `: `explain` prints visible or + hidden and which rule decided, the fastest way to test a scope. `zn mcp profile add/edit` + writes `mcp.json` with the same validator. +- `zn help` lists the new `zn mcp` flags; `:mcp` in `zn tui` shows the vault's profiles read-only. + +### Docs, both surfaces in the same change (zennotes CLAUDE.md rule) + +- In-app manual: `packages/app-core/src/lib/help.ts`, MCP card: profiles, flags, what read / + comment / edit mean, the shell-access caveat, fail-closed messages. +- Website: `resources/views/docs.blade.php` and `docs-sections.json` in the Laravel repo. +- This repo: README MCP section and `docs/desktop-integration.md`. +- The issue thread, when it ships (not before). + +## 10. Prior art + +- **Reference filesystem server** (`@modelcontextprotocol/server-filesystem`): allowed + directories as launch arguments; CVE-2025-53109 / GHSA-q66q-fx2p-7w4m was a path validation + bypass through prefix matching and symlinks in allowed directories, fixed in 2025.7.1. + Lessons taken: segment-boundary matching, canonical paths, symlinks as the first thing to + test. ([Wiz](https://www.wiz.io/vulnerability-database/cve/cve-2025-53109), + [GitLab advisory](https://advisories.gitlab.com/pkg/npm/@modelcontextprotocol/server-filesystem/CVE-2025-53109/)) +- **MCP roots:** client-provided `file://` roots; servers "SHOULD respect root boundaries", and + the docs call roots a coordination mechanism, not a security boundary. So roots can only + ever *narrow* a ZenNotes scope (a possible Phase 3 extra), never define one. + ([client concepts](https://modelcontextprotocol.io/docs/2025-03-26/learn/client-concepts), + [spec 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/client)) +- **GitHub MCP server:** `--read-only` drops write tools at registration, `--toolsets` loads + groups, options compose; lockdown mode is a best-effort content filter, not an authorization + boundary. Lessons: do not list what a client cannot use; restrictions compose. + ([configuration guide](https://glama.ai/mcp/servers/@github/github-mcp-server/blob/bf6467855c13d3b8f5cf72f8dfd9c86f8336b20b/docs/server-configuration.md), + [NeuralTrust](https://neuraltrust.ai/blog/how-to-secure-github-mcp)) +- **Obsidian MCP servers:** `obsidian-mcp-server` has `OBSIDIAN_READ_PATHS`, + `OBSIDIAN_WRITE_PATHS`, `OBSIDIAN_READ_ONLY` gating every path-taking tool, with write tools + disabled at registration; a Daily Notes server has per-vault `allowedRoots` / + `excludedRoots`, read-only by default. Separate read and write path sets are an idea worth + keeping for later (open question 9). (from their changelogs and listings, not verified + against current READMEs: + [changelog](https://unpkg.com/obsidian-mcp-server@3.2.4/changelog/3.1.x/3.1.2.md), + [Daily Notes MCP](https://glama.ai/mcp/servers/nmt8vee4zb), + [Obsidian Scoped-write MCP](https://glama.ai/mcp/servers/pa06gualox)) +- **Notion integrations:** default-deny by page sharing (children inherit), with separate + capabilities for read / update / insert content and read / insert comments. The model for + the `comment` tier. ([capabilities](https://developers.notion.com/reference/capabilities)) +- **Cursor `.cursorignore`, Gemini `.aiexclude`:** ignore files in the content tree; Cursor + documents that MCP and terminal tools are not blocked and that it is best-effort. The + lesson for #21 is the reporter's: enforcement has to be in the server. + ([Cursor](https://cursor.com/docs/context/ignore-files), + [Gemini Code Assist](https://docs.cloud.google.com/gemini/docs/codeassist/create-aiexclude-file)) + +## 11. Phased plan + +Estimates are focused engineer-days for someone who knows both codebases, including tests. + +| Phase | Scope | Repos | Effort | +|-------|-------|-------|--------| +| 0. Hardening (ship first, on its own) | Move/rename/delete sources and destinations must classify into a note bucket; subpaths cannot reach hidden or system directories in root mode (G1 to G4); TS realpath step like Go `SafeJoin` (G5); `read_note` only `.md`; desktop engine `rename_folder` rewrites vault.json lists; tests from the probe cases | tui, zennotes | 2 | +| 1a. Scope core, flags only, Go | `--allow-path` / `--deny-path` / `--read-only` / `--access`; evaluator, canonical walk, decorator, dispatcher gate, uniform errors, `vault_info.access`, instructions paragraph, annotations, tool parity test; ships in a zn release and reaches the desktop through the zn pin | tui | 4 | +| 1b. Scope core, desktop engine | `shared-domain/mcp-scope.ts` (normative) + fixture export, TS decorator, flags on the TS `zn mcp` and argv on `mcp.js`; Go consumes the fixture | zennotes, tui | 4 | +| 2. Profiles | `.zennotes/mcp.json` read/write and validation, `--profile` / `ZENNOTES_MCP_PROFILE`, fail-closed rules, rename rewrite in all hosts, znserver profiles endpoint, Settings → MCP Assistant access + client Profile select, sidebar menu, palette, Vim binding, `zn mcp profile`, docs on both surfaces | zennotes, tui, znserver, website | 7 | +| 3. Extras (each optional) | Referenced assets; per-note frontmatter opt-out (`mcp: false`, rename-proof, like `tasks: false`); MCP roots as extra narrowing; per-folder access levels; globs | as needed | 1 to 2 each | +| 4. Server-side (with the parked remote connector) | znserver `/mcp` enforcing profiles; scoped server tokens | znserver | separate design | + +Phases 0 and 1a alone answer the issue as filed. Phase 2 is what makes it usable from the +app without hand-editing client configs. + +## 12. Decisions + +Adib, 2026-10-29: "follow your recommendations". Settled: + +1. **Profiles live in `.zennotes/mcp.json`** per vault (4.2), not vault.json or config.toml. +2. **No implicit default profile.** A client without `--profile` keeps full access; restricting + every assistant means giving each client a profile. +3. **A stale `deny` entry denies everything** until it is fixed. This applies to + `--deny-path` too: a flag naming a folder the current vault does not have shares nothing. +4. **Folders and notes only in v1**, no globs. +5. **Lifecycle exits are allowed:** a scoped write client may archive or trash a visible note + into an archive or trash it cannot see. +6. **Under a scope, rename rewrites `[[links]]` in visible notes only.** The engines' existing + difference (Go zn and znserver rewrite every note, the desktop engine none) is settled + separately. +7. **`rename_folder` / `delete_folder` stay available** when source and destination lie + inside `allow` entries and no `deny` entry is inside the subtree; the one-bit leak is + documented. +8. **Three access tiers:** `read`, `comment`, `write`. +9. **Per-folder access levels** are a Phase 3 option, not v1. +10. **Phase 0 ships first, on its own** (started 2026-10-29 in its own session; merged on its + own as ZenNotes/tui#23, then released with Phase 1a in zn 0.13.0 and desktop 2.80.0). +11. **`Space l h`** for the folder share/hide action. + +Still open: whether Phase 0's release notes carry an advisory line (zn 0.13.0's notes list +it as a fix, with no advisory), and when to answer lewxdev on #21 (and whether to invite +their review of this design). + +## 13. Phase 1a as built + +Shipped in zn 0.13.0 (2026-10-30), with Phase 0 under it: + +- `internal/mcp/scope.go`: `Scope`, `NewScope` (flag validation), segment matching, + `toolClasses` and `toolAllowed`, tool annotations, the instructions paragraph, the + `vault_info.access` block. +- `internal/mcp/scoped.go`: `scopedBackend`, one per tool call, resolving every path + against the listings (4.4) and implementing every `Backend` method; the ones MCP never + reaches (templates, settings writes, asset bytes, databases) refuse. +- `internal/mcp/server.go`: `Options.Scope`; unlisted tools are not registered; every call + runs on the scoped backend; `vault_info` drops absolute paths and adds `access`; + `DescribeToolError` scrubs the vault root from a scoped client's errors; + `CallToolScoped` is the test seam. +- `internal/vault`: `RenameNoteRewritingLinksIn` (the rename with a filter on which notes + get their links rewritten) and `IsReservedRootName`; `internal/backend/local.go` exposes the + rename. `internal/cli`: the four flags (`--read-only` is a switch) and the help row. +- Tests: `internal/mcp/scope_test.go`, through the in-memory MCP transport. A mutation that + disables deny matching fails five of them. +- Verified with the built binary over stdio: `zn mcp --allow-path /projects --read-only` + lists 12 tools, shares only `Projects/`, answers `Note not found` for a hidden note and for + the symlinked path to it, and treats `move_note` / `write_note` as unknown tools; + `--allow-path .zennotes` stops the server before it serves. + +Differences from the design above: path resolution through the listings for local vaults +too (4.4); the stale-deny rule applies to `--deny-path` as well as to profiles (decision 3); +remote renames are refused under a path restriction rather than rewriting hidden notes. + +Not in Phase 1a: the desktop engine (1b), profiles and the Settings UI (2), the shared +fixture (lands with 1b, which owns the normative TS module). + +## 14. Phase 1b as built + +In ZenNotes/zennotes as 7be9e993 (on top of Phase 0, landing in desktop 2.80.0), and here +as the link fix and the contract test in zn 0.13.0: + +- `packages/shared-domain/src/mcp-scope.ts`, the normative module: flag parsing and entry + validation with zn's refusal bytes (Go's `%q` included), `foldMcpScopePath` (`search.Fold`, + lowered one code point at a time so JavaScript's final sigma does not apply), segment + matching, `MCP_TOOL_ACCESS` and the annotations, the access refusals, the instructions + paragraph, the `vault_info.access` block and code-point sorting. +- `apps/desktop/src/mcp/scoped-backend.ts`: `ScopedBackend`, the `VaultBackend` decorator, + one per call, ported from `scoped.go`. `VaultBackend` gained `vaultLayout()` (system folder + paths and the primary location, local and remote), the classification input Go reads from + `VaultSettings`. +- `server.ts`: tools a scope does not offer are not listed, every tool carries annotations + (`readOnlyHint` and `idempotentHint` spelled out even when false, as the Go SDK sends them), + the scoped `vault_info`, scrubbed errors, the instructions paragraph; + `callTool(name, args, backend, scope)` is the test seam. +- Flags on the desktop's TS `zn mcp` (`read-only` joined `VALUELESS_FLAGS`, and the comment on + `valuelessFlags` here no longer calls it zn's own) and argv on the legacy `out/main/mcp.js`. + A bad entry stops either before it serves, with one line on stderr. +- The contract fixture `packages/bridge-contract/fixtures/mcp-scope.json`: flag cases and tool + cases on three vaults (root mode with a link to a hidden folder and one out of the vault, + accented names, inbox mode with Inbox and Archive remapped), each checking the tools listed, + every call's result or exact error and the disk afterwards. The desktop runs it in + `apps/desktop/src/mcp/scope-contract.test.ts`, this repo in + `internal/mcp/scope_contract_test.go` from a copy with its `.source.json`; + `npm run sync:contract-fixtures -- --tui ` refreshes the copy. + +Found while building it, and fixed in both engines: a destination segment the listings do +not know was taken as new. The listings skip symbolic links, so under `--allow-path Projects`, +`create_note`, `create_folder`, `move_note` and `rename_folder` into `Projects/inner` (a link +to the hidden `Journal`) wrote into `Journal`. On a vault on this machine, the first segment +of a destination the listings do not know must now have nothing on disk (anything but "does +not exist" refuses, with the usual refusal); fixture case `nothing-is-created-through-a-link`. +A server's vault is left to the server's own walker. + +Engine differences that remain, left out of the fixture on purpose: + +- Renames: the desktop engine never rewrites inbound links, scoped or not, so decision 6 + holds trivially there; zn rewrites them in the notes the client can see. +- `list_assets`: zn lists every non-note file, the desktop engine the attachments folders + only. Both filter by visibility. +- A tool the scope does not offer: zn answers with a JSON-RPC error (the SDK's unknown tool), + the desktop engine with an `isError` result `Unknown tool: X`, as each already did for any + unknown name. +- A bad flag: zn and the desktop's `zn mcp` print `zn: `, the legacy `mcp.js` + `[zennotes-mcp] `. + +Pre-existing and unscoped, seen in the same probe: the wording of a missing file's error, +`add_comment`'s author for a client neither engine knows (zn null, the desktop the client's +name capitalised), and the desktop engine creating an archive or trash folder before it finds +that the note to move is gone. + +Verified: both engines pass the fixture; the stdio parity probe (`probe_scoped.py`, in the +session notes) gives identical transcripts for every scoped flag set on Go (fbaf94c plus the +fix, with and without Phase 0 merged in) and on both TS entries, apart from the differences +above. Not in 1b: profiles and the Settings UI (Phase 2). diff --git a/docs/shared-contracts.md b/docs/shared-contracts.md index 07275e9..0a565ed 100644 --- a/docs/shared-contracts.md +++ b/docs/shared-contracts.md @@ -8,6 +8,11 @@ behavior fixtures from `ZenNotes/zennotes`, not another repository's source tree - `internal/remote/testdata/self-hosted-http.json` covers authentication, note metadata, exact Unicode/whitespace, missing resources and invalid paths at both `/` and `/notes`. +- `internal/mcp/testdata/mcp-scope.json` covers scoped MCP access (#21): flag + validation, the tools each scope lists and their annotations, every tool's + answer for shared, hidden, symlinked, case- and accent-variant paths in root, + inbox and remapped vaults, and what is on disk afterwards. The desktop's + bundled MCP server runs the same cases. - Adjacent `.source.json` files record each upstream path and SHA-256. Tests verify the checked-in bytes before interpreting the contract. diff --git a/internal/backend/local.go b/internal/backend/local.go index 8d9925c..6041886 100644 --- a/internal/backend/local.go +++ b/internal/backend/local.go @@ -72,8 +72,14 @@ func (l *Local) AppendToNote(_ context.Context, rel, text string) (vault.NoteMet func (l *Local) PrependToNote(_ context.Context, rel, text string) (vault.NoteMeta, error) { return l.vault.PrependToNote(rel, text) } -func (l *Local) RenameNote(_ context.Context, rel, nextTitle string) (vault.NoteMeta, error) { - meta, err := l.vault.RenameNote(rel, nextTitle) +func (l *Local) RenameNote(ctx context.Context, rel, nextTitle string) (vault.NoteMeta, error) { + return l.RenameNoteRewritingLinksIn(ctx, rel, nextTitle, nil) +} + +// RenameNoteRewritingLinksIn renames a note but rewrites inbound links only +// in the notes rewrite accepts (nil: every note). +func (l *Local) RenameNoteRewritingLinksIn(_ context.Context, rel, nextTitle string, rewrite func(path string) bool) (vault.NoteMeta, error) { + meta, err := l.vault.RenameNoteRewritingLinksIn(rel, nextTitle, rewrite) if err != nil { return vault.NoteMeta{}, err } diff --git a/internal/cli/args.go b/internal/cli/args.go index f8a6635..09101c8 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -57,6 +57,7 @@ var valuelessFlags = map[string]bool{ "page": true, "path": true, "pretty": true, + "read-only": true, "reopen": true, "unchecked": true, "yes": true, diff --git a/internal/cli/base_add_test.go b/internal/cli/base_add_test.go new file mode 100644 index 0000000..53e3e4f --- /dev/null +++ b/internal/cli/base_add_test.go @@ -0,0 +1,203 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The schema.json the app writes (JSON.stringify with two spaces and a +// trailing newline) for a database with three views: Shelf and Later show +// only their picked rows, All shows every row. Shelf's picks sit between +// other keys, so a rewrite that moved them would show. +const pickedSchemaFormat = `{ + "version": 1, + "idFieldId": "f_id", + "fields": [ + { + "id": "f_id", + "name": "id", + "type": "text", + "hidden": true + }, + { + "id": "f_title", + "name": "Title", + "type": "text" + } + ], + "views": [ + { + "id": "shelf", + "name": "Shelf", + "type": "table", + "filters": [], + "sorts": [], + "pickedRowIds": [ + "r1" + ], + "columnOrder": [ + "f_id", + "f_title" + ] + }, + { + "id": "all", + "name": "All", + "type": "table", + "filters": [], + "sorts": [] + }, + { + "id": "later", + "name": "Later", + "type": "table", + "filters": [], + "sorts": [], + "pickedRowIds": [ + "r2" + ] + } + ], + "activeViewId": "%s" +} +` + +func pickedReadingVault(t *testing.T) (root, base string) { + t.Helper() + isolatedCLI(t) + root = t.TempDir() + base = filepath.Join(root, "inbox", "Reading.base") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "data.csv"), []byte("id,Title\nr1,Dune\nr2,Emma\n"), 0o644); err != nil { + t.Fatal(err) + } + return root, base +} + +// openOn writes the schema with the database opening on activeViewID and +// returns the bytes written. +func openOn(t *testing.T, base, activeViewID string) string { + t.Helper() + text := fmt.Sprintf(pickedSchemaFormat, activeViewID) + if err := os.WriteFile(filepath.Join(base, "schema.json"), []byte(text), 0o644); err != nil { + t.Fatal(err) + } + return text +} + +func runBaseAdd(t *testing.T, root string, flags ...string) string { + t.Helper() + args := append([]string{"base", "add", "Reading", "--vault", root}, flags...) + return captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// As + Row does in the app's grid, a row `zn base add` makes joins the picks +// of the view the database opens on (activeViewId), so that view shows it +// the next time the database is opened. The same rule as the desktop CLI's +// cmdBaseAdd (addRowInView). +func TestBaseAddJoinsThePicksOfTheViewTheDatabaseOpensOn(t *testing.T) { + root, base := pickedReadingVault(t) + before := openOn(t, base, "shelf") + out := runBaseAdd(t, root, "--set", "Title=Ulysses", "--json") + var added map[string]any + if err := json.Unmarshal([]byte(out), &added); err != nil { + t.Fatalf("%v\n%s", err, out) + } + id, _ := added["id"].(string) + if added["ok"] != true || id == "" || added["title"] != "Ulysses" || added["pickedInView"] != "Shelf" { + t.Fatalf("json: %v", added) + } + // Shelf gains the row, in place; Later keeps its own picks, and All + // gains none. Nothing else in schema.json moves. + want := strings.Replace(before, "\"r1\"\n ],", "\"r1\",\n \""+id+"\"\n ],", 1) + if got := readFile(t, filepath.Join(base, "schema.json")); got != want { + t.Fatalf("schema.json:\n%s\nwant:\n%s", got, want) + } + if got := readFile(t, filepath.Join(base, "data.csv")); got != "id,Title\nr1,Dune\nr2,Emma\n"+id+",Ulysses\n" { + t.Fatalf("data.csv: %q", got) + } +} + +// Opening on a view of every row, the add changes no view's picks, and +// schema.json is not rewritten at all. +func TestBaseAddLeavesEveryViewAloneWhenTheOpeningViewHasNoPicks(t *testing.T) { + root, base := pickedReadingVault(t) + before := openOn(t, base, "all") + out := runBaseAdd(t, root, "--set", "Title=Middlemarch") + if !strings.HasPrefix(out, "Added Middlemarch (") || strings.Contains(out, "picked rows") { + t.Fatalf("output: %q", out) + } + if got := readFile(t, filepath.Join(base, "schema.json")); got != before { + t.Fatalf("schema.json changed:\n%s", got) + } + if got := readFile(t, filepath.Join(base, "data.csv")); !strings.Contains(got, ",Middlemarch\n") { + t.Fatalf("data.csv: %q", got) + } + + out = runBaseAdd(t, root, "--set", "Title=Persuasion", "--json") + var added map[string]any + if err := json.Unmarshal([]byte(out), &added); err != nil { + t.Fatalf("%v\n%s", err, out) + } + if _, ok := added["pickedInView"]; ok || added["title"] != "Persuasion" { + t.Fatalf("json: %v", added) + } +} + +// The text output says where the row went, before the page line, and a +// record page and the picks land in schema.json together. +func TestBaseAddSaysWhichViewsPicksTheRowJoined(t *testing.T) { + root, base := pickedReadingVault(t) + openOn(t, base, "later") + out := runBaseAdd(t, root, "--set", "Title=Kindred", "--page") + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "Added Kindred (") || + lines[1] != " added to the picked rows of the Later view" || + lines[2] != " page: inbox/Reading.base/Kindred.md" { + t.Fatalf("output: %q", out) + } + id := strings.TrimSuffix(strings.TrimPrefix(lines[0], "Added Kindred ("), ")") + var schema struct { + Views []struct { + ID string `json:"id"` + PickedRowIDs *[]string `json:"pickedRowIds"` + } `json:"views"` + ActiveViewID string `json:"activeViewId"` + Pages map[string]string `json:"pages"` + } + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(base, "schema.json"))), &schema); err != nil { + t.Fatal(err) + } + picks := map[string][]string{} + for _, v := range schema.Views { + if v.PickedRowIDs != nil { + picks[v.ID] = *v.PickedRowIDs + } + } + if !reflect.DeepEqual(picks, map[string][]string{"shelf": {"r1"}, "later": {"r2", id}}) { + t.Fatalf("picks: %v", picks) + } + if schema.ActiveViewID != "later" || schema.Pages[id] != "Kindred.md" { + t.Fatalf("active %q, pages %v", schema.ActiveViewID, schema.Pages) + } +} diff --git a/internal/cli/commands_base.go b/internal/cli/commands_base.go index 3e91208..01a8274 100644 --- a/internal/cli/commands_base.go +++ b/internal/cli/commands_base.go @@ -338,11 +338,20 @@ func cmdBaseAdd(ctx context.Context, b backend.Backend, args Args) error { if err != nil { return err } - row := doc.AddRow() + // As + Row does in the app's grid: when the view the database opens on + // shows picked rows, the new row joins them, or it would be missing from + // that view the next time the database is opened. Other views keep their + // picks. + row, picksChanged := doc.AddRowInView(doc.ActiveViewID) + pickedIn := "" + if view := doc.ViewByID(doc.ActiveViewID); picksChanged && view != nil { + pickedIn = view.Name + } schemaChanged, err := applyAssignments(doc, row.ID, assignments) if err != nil { return err } + schemaChanged = schemaChanged || picksChanged pagePath := "" if wantPage { current := *doc.RowByID(row.ID) @@ -374,10 +383,14 @@ func cmdBaseAdd(ctx context.Context, b backend.Backend, args Args) error { emitJSON(struct { OK bool `json:"ok"` rowView - }{true, view}) + PickedInView string `json:"pickedInView,omitempty"` + }{true, view, pickedIn}) return nil } emitOK(fmt.Sprintf("Added %s (%s)", doc.RecordTitle(finalRow), row.ID)) + if pickedIn != "" { + emitLine(" added to the picked rows of the " + pickedIn + " view") + } if pagePath != "" { emitLine(" page: " + pagePath) } diff --git a/internal/cli/commands_mcp_tui.go b/internal/cli/commands_mcp_tui.go index 3443a89..b131616 100644 --- a/internal/cli/commands_mcp_tui.go +++ b/internal/cli/commands_mcp_tui.go @@ -15,13 +15,39 @@ import ( // follows the desktop app the way a fresh zn does and starting before // anything is configured works; after a switch, calls stop until vault_info // confirms the new vault, so a switch never redirects in-flight edits. +// +// --allow-path, --deny-path, --read-only and --access narrow what the client +// may see and change (ZenNotes/tui#21); the server enforces them on every +// tool, and a bad entry stops it before it serves anything. func cmdMCP(ctx context.Context, args Args) error { + scope, err := mcpScopeFromArgs(args) + if err != nil { + return err + } return mcp.Run(ctx, mcp.Options{ ResolveTarget: func() (backend.Target, error) { return ResolveTargetFromArgs(args) }, OpenBackend: OpenBackend, + Scope: scope, }) } +// mcpScopeFromArgs reads the scope flags; nil means none were given, today's +// full access. +func mcpScopeFromArgs(args Args) (*mcp.Scope, error) { + allow := args.Flags["allow-path"] + deny := args.Flags["deny-path"] + access := args.Str("access") + readOnly := args.Bool("read-only") + if len(allow) == 0 && len(deny) == 0 && access == "" && !readOnly { + return nil, nil + } + scope, err := mcp.NewScope(access, readOnly, allow, deny) + if err != nil { + return nil, err + } + return &scope, nil +} + // cmdTUI opens the terminal app on the resolved vault; a positional path // opens that note first. func cmdTUI(ctx context.Context, args Args) error { diff --git a/internal/cli/help.go b/internal/cli/help.go index 7817f3c..a0f1bfb 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -91,7 +91,7 @@ var helpSections = []helpSection{ {"base create ", "Create an empty database", "--folder <f/sub> --json"}, {"base rows <base>", "List a database’s rows (title or path picks the base)", "--json"}, {"base get <base> <row>", "One row by id or title value", "--json"}, - {"base add <base>", "Add a row; --body (or --body - for stdin) also creates its record page", "--set Field=Value … --body <text|-> --page --json"}, + {"base add <base>", "Add a row; --body (or --body - for stdin) also creates its record page. When the view the database opens on shows picked rows, the row joins them", "--set Field=Value … --body <text|-> --page --json"}, {"base set <base> <row>", "Set fields; select options mint, the page re-mirrors", "--set Field=Value … --json"}, {"base convert <base>", "Turn a loose .csv into a .base folder so rows can have record pages", "--json"}, {"base pages <base>", "Give every record without one its record page, so each can be linked", "--dry-run --json"}, @@ -106,7 +106,7 @@ var helpSections = []helpSection{ {"tui", "Open ZenNotes in the terminal: sidebar, tabs, splits, Vim motions, tasks, tags", "--vault <v> --server <s> [path]"}, }}, {"MCP", []helpRow{ - {"mcp", "Start the MCP stdio server (Claude / Codex)", ""}, + {"mcp", "Start the MCP stdio server (Claude / Codex). --allow-path and --deny-path share only part of the vault; --read-only, or --access comment, offers fewer tools", "--allow-path <p>… --deny-path <p>… --read-only --access read|comment|write"}, }}, {"CONFIGURATION", []helpRow{ {"config", "Open config.toml in $EDITOR, creating a commented starter file", "--path --no-edit"}, diff --git a/internal/database/doc.go b/internal/database/doc.go index b7a46a7..c2079b0 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -438,6 +438,32 @@ func (d *Doc) AddRow() Row { return row } +// AddRowInView appends an empty row while viewID is the view being looked +// at. A view whose schema.json entry has a pickedRowIds list shows only +// those rows in the app, so the new row joins that list, or it would be +// missing from the view it was added in; picksChanged then says schema.json +// changed too, since a rows-only write leaves it alone. Other views keep +// their picks: a curated view nobody is looking at does not collect every +// row added elsewhere. Mirrors addRowInView in the app's shared-domain +// database-records.ts: the TUI passes the view on screen, `zn base add` the +// database's active view. +func (d *Doc) AddRowInView(viewID string) (row Row, picksChanged bool) { + row = d.AddRow() + view := d.viewObject(viewID) + if view == nil { + return row, false + } + value, _ := view.Get("pickedRowIds") + picked, ok := value.([]any) + if !ok { + return row, false + } + next := make([]any, 0, len(picked)+1) + next = append(append(next, picked...), row.ID) + view.Set("pickedRowIds", next) + return row, true +} + // DeleteRow removes a row. func (d *Doc) DeleteRow(rowID string) { out := d.Rows[:0] diff --git a/internal/database/picks_test.go b/internal/database/picks_test.go new file mode 100644 index 0000000..7297a95 --- /dev/null +++ b/internal/database/picks_test.go @@ -0,0 +1,96 @@ +package database + +import ( + "reflect" + "testing" +) + +func picksDoc(t *testing.T) *Doc { + t.Helper() + sidecar, err := ParseObject(`{"version":1,"idFieldId":"f0","fields":[{"id":"f0","name":"id","type":"text","hidden":true},{"id":"f1","name":"Title","type":"text"}],"views":[` + + `{"id":"shelf","name":"Shelf","type":"table","filters":[],"sorts":[],"pickedRowIds":["r1"]},` + + `{"id":"all","name":"All","type":"table","filters":[],"sorts":[]},` + + `{"id":"later","name":"Later","type":"table","filters":[],"sorts":[],"pickedRowIds":["r2"]},` + + `{"id":"none","name":"None yet","type":"table","filters":[],"sorts":[],"pickedRowIds":[]},` + + `{"id":"cleared","name":"Cleared","type":"table","filters":[],"sorts":[],"pickedRowIds":null}` + + `],"activeViewId":"shelf"}`) + if err != nil { + t.Fatal(err) + } + d := &Doc{Path: "Reading.base/data.csv", Sidecar: sidecar, Rows: []Row{ + {ID: "r1", Cells: map[string]string{"f0": "r1", "f1": "Dune"}}, + {ID: "r2", Cells: map[string]string{"f0": "r2", "f1": "Emma"}}, + }} + if err := d.Resync(); err != nil { + t.Fatal(err) + } + return d +} + +// picks reads a view's pickedRowIds as the sidecar will write them, and +// whether the key is there at all. +func picks(t *testing.T, d *Doc, viewID string) ([]string, bool) { + t.Helper() + view := d.viewObject(viewID) + if view == nil { + t.Fatalf("no view %q", viewID) + } + value, ok := view.Get("pickedRowIds") + if !ok { + return nil, false + } + list, isList := value.([]any) + if !isList { + return nil, true + } + return stringsFromArray(list), true +} + +// The same rules as addRowInView in the app's shared-domain +// database-records.ts. +func TestAddRowInViewJoinsOnlyThatViewsPicks(t *testing.T) { + d := picksDoc(t) + row, changed := d.AddRowInView("shelf") + if !changed || len(d.Rows) != 3 || d.Rows[2].ID != row.ID { + t.Fatalf("add in a picks view: changed %v, rows %d", changed, len(d.Rows)) + } + if got, _ := picks(t, d, "shelf"); !reflect.DeepEqual(got, []string{"r1", row.ID}) { + t.Fatalf("shelf picks: %v", got) + } + // A view with picks nobody is looking at keeps them. + if got, _ := picks(t, d, "later"); !reflect.DeepEqual(got, []string{"r2"}) { + t.Fatalf("later picks: %v", got) + } + if _, ok := picks(t, d, "all"); ok { + t.Fatal("a view of every row gained picks") + } + + // Adding in a view of every row changes no view. + if _, changed := d.AddRowInView("all"); changed { + t.Fatal("a view without picks reported a picks change") + } + if got, _ := picks(t, d, "shelf"); !reflect.DeepEqual(got, []string{"r1", row.ID}) { + t.Fatalf("shelf picks after an add elsewhere: %v", got) + } + if got, _ := picks(t, d, "later"); !reflect.DeepEqual(got, []string{"r2"}) { + t.Fatalf("later picks after an add elsewhere: %v", got) + } + if _, ok := picks(t, d, "all"); ok { + t.Fatal("a view of every row gained picks") + } + + // An empty list is a view of no rows, not of every row: the row joins it. + row3, changed := d.AddRowInView("none") + if got, _ := picks(t, d, "none"); !changed || !reflect.DeepEqual(got, []string{row3.ID}) { + t.Fatalf("empty picks: changed %v, %v", changed, got) + } + // null is no picks, as the app reads it. + if _, changed := d.AddRowInView("cleared"); changed { + t.Fatal("pickedRowIds: null is not a list of picks") + } + // An unknown view adds the row and touches no view. + before := len(d.Rows) + if _, changed := d.AddRowInView("gone"); changed || len(d.Rows) != before+1 { + t.Fatalf("unknown view: changed %v, rows %d", changed, len(d.Rows)) + } +} diff --git a/internal/mcp/scope.go b/internal/mcp/scope.go new file mode 100644 index 0000000..8fad94d --- /dev/null +++ b/internal/mcp/scope.go @@ -0,0 +1,296 @@ +package mcp + +import ( + "errors" + "fmt" + "sort" + "strings" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ZenNotes/tui/internal/search" +) + +// Scoped access (ZenNotes/tui#21, docs/ideas/mcp-scoped-access.md): what an +// MCP client may see and change, fixed when the server starts from its own +// launch flags. No tool argument can name or widen a scope; tools only narrow +// inside it. The rules match the desktop's bundled server so a client gets +// the same answers whichever binary its config launches. + +// Access is how much a client may do to what it can see. +type Access int + +const ( + // AccessRead offers the read tools only. + AccessRead Access = iota + // AccessComment adds the comment tools. + AccessComment + // AccessWrite is every tool the paths allow. + AccessWrite +) + +func (a Access) String() string { + switch a { + case AccessRead: + return "read" + case AccessComment: + return "comment" + default: + return "write" + } +} + +// ParseAccess reads `read`, `comment` or `write`. +func ParseAccess(raw string) (Access, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "read", "read-only", "readonly": + return AccessRead, nil + case "comment": + return AccessComment, nil + case "write", "edit": + return AccessWrite, nil + } + return AccessRead, fmt.Errorf("--access must be read, comment or write, not %q", raw) +} + +// Scope is an access level plus the paths a client may see. Allow empty means +// the whole vault; a non-empty Allow shows only what it names (default-deny), +// and Deny hides paths even inside it. +type Scope struct { + Access Access + Allow []string + Deny []string +} + +// Restricted says whether the scope narrows the paths at all. +func (s Scope) Restricted() bool { return len(s.Allow) > 0 || len(s.Deny) > 0 } + +// Active says whether the scope changes anything compared with full access. +func (s Scope) Active() bool { return s.Restricted() || s.Access != AccessWrite } + +// NewScope builds a scope from `zn mcp` flags. readOnly lowers access to read +// whatever access says; an empty access is write, today's behavior. +func NewScope(access string, readOnly bool, allow, deny []string) (Scope, error) { + s := Scope{Access: AccessWrite} + if strings.TrimSpace(access) != "" { + a, err := ParseAccess(access) + if err != nil { + return Scope{}, err + } + s.Access = a + } + if readOnly { + s.Access = AccessRead + } + var err error + if s.Allow, err = normalizeScopeEntries("--allow-path", allow); err != nil { + return Scope{}, err + } + if s.Deny, err = normalizeScopeEntries("--deny-path", deny); err != nil { + return Scope{}, err + } + return s, nil +} + +// normalizeScopeEntries validates entries the way the Tasks folder lists are +// validated (forward slashes, trimmed segments, no `.` or `..`, at most 512 +// characters, duplicates dropped), and refuses hidden entries, which never +// hold anything a client can see. A leading slash is accepted: an entry is +// always vault-relative. A bad entry is an error, never a silent widening. +func normalizeScopeEntries(flag string, values []string) ([]string, error) { + out := []string{} + seen := map[string]struct{}{} + for _, raw := range values { + parts := []string{} + for _, seg := range strings.Split(strings.ReplaceAll(raw, "\\", "/"), "/") { + s := strings.TrimSpace(seg) + if s == "" { + continue + } + if s == "." || s == ".." { + return nil, fmt.Errorf("%s %q: . and .. are not allowed; name a folder or note inside the vault", flag, raw) + } + if strings.HasPrefix(s, ".") { + return nil, fmt.Errorf("%s %q names a hidden folder, which an assistant never sees", flag, raw) + } + if strings.ContainsRune(s, 0) { + return nil, fmt.Errorf("%s %q is not a valid path", flag, raw) + } + parts = append(parts, s) + } + if len(parts) == 0 { + return nil, fmt.Errorf("%s %q is empty; name a folder or note inside the vault", flag, raw) + } + joined := strings.Join(parts, "/") + if len(joined) > 512 { + return nil, fmt.Errorf("%s %q is longer than 512 characters", flag, raw) + } + if _, dup := seen[joined]; dup { + continue + } + seen[joined] = struct{}{} + out = append(out, joined) + } + return out, nil +} + +// inside is the segment-prefix rule of the Tasks folder lists: `Projects` +// covers `Projects/x.md`, never `Projects2/x.md`. +func inside(p, entry string) bool { + return p == entry || strings.HasPrefix(p, entry+"/") +} + +// insideFolded is inside after case and accent folding. Deny entries match +// this way on purpose: on a case- or normalization-insensitive volume a +// near-miss must over-hide, never leak. +func insideFolded(p, entry string) bool { + return inside(search.Fold(p), search.Fold(entry)) +} + +// --- tools --- + +type toolClass int + +const ( + classRead toolClass = iota + classComment + classWrite + // classUnscopedWrite acts on content regardless of path (empty_trash + // deletes every trashed note), so no path-restricted client gets it. + classUnscopedWrite +) + +// toolClasses says what each tool needs. A tool missing here is treated as +// classUnscopedWrite, and a test fails until it is classified. +var toolClasses = map[string]toolClass{ + "vault_info": classRead, + "list_notes": classRead, + "list_folders": classRead, + "list_assets": classRead, + "read_note": classRead, + "search_text": classRead, + "search_by_title": classRead, + "search_by_tag": classRead, + "list_tags": classRead, + "backlinks": classRead, + "list_tasks": classRead, + "list_comments": classRead, + "add_comment": classComment, + "reply_to_comment": classComment, + "resolve_comment": classComment, + "toggle_task": classWrite, + "write_note": classWrite, + "append_to_note": classWrite, + "prepend_to_note": classWrite, + "insert_at_line": classWrite, + "replace_in_note": classWrite, + "create_note": classWrite, + "rename_note": classWrite, + "move_note": classWrite, + "duplicate_note": classWrite, + "archive_note": classWrite, + "unarchive_note": classWrite, + "move_to_trash": classWrite, + "restore_from_trash": classWrite, + "delete_note": classWrite, + "create_folder": classWrite, + "rename_folder": classWrite, + "delete_folder": classWrite, + "empty_trash": classUnscopedWrite, +} + +// destructiveTools may destroy content rather than add to it or move it. +var destructiveTools = map[string]bool{ + "write_note": true, + "replace_in_note": true, + "delete_note": true, + "delete_folder": true, + "empty_trash": true, +} + +func classOf(name string) toolClass { + if c, ok := toolClasses[name]; ok { + return c + } + return classUnscopedWrite +} + +// toolAllowed says whether a scope offers a tool. A nil scope is full access. +func toolAllowed(name string, s *Scope) bool { + if s == nil { + return true + } + switch classOf(name) { + case classRead: + return true + case classComment: + return s.Access >= AccessComment + case classWrite: + return s.Access >= AccessWrite + default: + return s.Access >= AccessWrite && !s.Restricted() + } +} + +// toolAnnotations tells clients which tools only read and which can destroy, +// so a client that confirms destructive calls can. The vault is a closed +// world. +func toolAnnotations(name string) *sdk.ToolAnnotations { + closed := false + if classOf(name) == classRead { + return &sdk.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closed} + } + destructive := destructiveTools[name] + return &sdk.ToolAnnotations{DestructiveHint: &destructive, OpenWorldHint: &closed} +} + +// accessError answers a call to a tool above the scope's access level. It +// names no path, so it says nothing about the vault. +func accessError(s Scope) error { + switch s.Access { + case AccessRead: + return errors.New("This assistant can only read this vault.") + case AccessComment: + return errors.New("This assistant can only read this vault and comment on notes.") + } + return errors.New("This assistant cannot use that tool on a vault it only partly sees.") +} + +// scopeInstructions is appended to the server instructions while a scope is +// active, so the model neither guesses at hidden notes nor tries tools it +// does not have. +func scopeInstructions(s Scope) string { + text := "\n\n## Access\n\n" + if s.Restricted() { + text += "This server shows part of the user's vault. Paths and notes you are not shown do not exist for you: do not guess at them, and treat a [[link]] that does not resolve as pointing outside what you can see. " + } + switch s.Access { + case AccessRead: + text += "You can read but not change anything. " + case AccessComment: + text += "You can read and comment on notes, but not change them. " + } + return text + "Call `vault_info` to see what you can reach." +} + +// describeScope is the scope as vault_info reports it. Deny entries are never +// named: the names alone can say too much. +func describeScope(s Scope, allow []string, missingAllow []string, blocked error) orderedResult { + allowOut := append([]string{}, allow...) + sort.Strings(allowOut) + missing := append([]string{}, missingAllow...) + sort.Strings(missing) + result := orderedResult{ + {"level", s.Access.String()}, + {"partial", s.Restricted()}, + {"allow", allowOut}, + {"missingAllow", missing}, + } + if blocked != nil { + result = append(result, struct { + key string + value any + }{"blocked", blocked.Error()}) + } + return result +} diff --git a/internal/mcp/scope_contract_test.go b/internal/mcp/scope_contract_test.go new file mode 100644 index 0000000..f60df25 --- /dev/null +++ b/internal/mcp/scope_contract_test.go @@ -0,0 +1,382 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// The shared scoped-access contract (ZenNotes/tui#21): vault trees, scopes, +// tool calls and the bytes a client must get back. The desktop's bundled MCP +// server runs the same cases in apps/desktop/src/mcp/scope-contract.test.ts +// (ZenNotes/zennotes), whose packages/bridge-contract/fixtures/mcp-scope.json +// this is a copy of; see docs/shared-contracts.md. When a case fails, settle +// which behavior is intended before changing anything; never copy this +// engine's output into the fixture. + +type scopeContractFlags struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` + Access string `json:"access"` + ReadOnly bool `json:"readOnly"` +} + +type scopeContractExpect struct { + Error *string `json:"error"` + OK bool `json:"ok"` + NotOffered bool `json:"notOffered"` + Paths *[]string `json:"paths"` + Folders *[]string `json:"folders"` + Match json.RawMessage `json:"match"` + Contains []string `json:"contains"` + Excludes []string `json:"excludes"` + AbsentKeys []string `json:"absentKeys"` +} + +type scopeContract struct { + SchemaVersion int `json:"schemaVersion"` + Vaults map[string]struct { + Files map[string]string `json:"files"` + Symlinks map[string]string `json:"symlinks"` + Outside map[string]string `json:"outside"` + } `json:"vaults"` + FlagCases []struct { + ID string `json:"id"` + Flags scopeContractFlags `json:"flags"` + Scope *struct { + Access string `json:"access"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` + } `json:"scope"` + Error string `json:"error"` + } `json:"flagCases"` + Cases []struct { + ID string `json:"id"` + Vault string `json:"vault"` + Flags *scopeContractFlags `json:"flags"` + Tools *struct { + Count *int `json:"count"` + Exactly []string `json:"exactly"` + Include []string `json:"include"` + Absent []string `json:"absent"` + Annotations map[string]map[string]any `json:"annotations"` + } `json:"tools"` + Calls []struct { + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Expect scopeContractExpect `json:"expect"` + } `json:"calls"` + Disk *struct { + Exists []string `json:"exists"` + Absent []string `json:"absent"` + Equals map[string]string `json:"equals"` + } `json:"disk"` + } `json:"cases"` +} + +func loadScopeContract(t *testing.T) scopeContract { + t.Helper() + data, err := os.ReadFile("testdata/mcp-scope.json") + if err != nil { + t.Fatal(err) + } + var provenance struct{ Sha256 string } + source, err := os.ReadFile("testdata/mcp-scope.json.source.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(source, &provenance); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != provenance.Sha256 { + t.Fatal("shared fixture checksum differs") + } + var c scopeContract + if err := json.Unmarshal(data, &c); err != nil { + t.Fatal(err) + } + if c.SchemaVersion != 1 || len(c.Cases) == 0 { + t.Fatal("unsupported or empty scope contract fixture") + } + return c +} + +func TestSharedScopeFlagContract(t *testing.T) { + for _, tc := range loadScopeContract(t).FlagCases { + t.Run(tc.ID, func(t *testing.T) { + s, err := NewScope(tc.Flags.Access, tc.Flags.ReadOnly, tc.Flags.Allow, tc.Flags.Deny) + if tc.Error != "" { + if err == nil || err.Error() != tc.Error { + t.Fatalf("got %v, want %q", err, tc.Error) + } + return + } + if err != nil { + t.Fatal(err) + } + if s.Access.String() != tc.Scope.Access || !reflect.DeepEqual(s.Allow, tc.Scope.Allow) || !reflect.DeepEqual(s.Deny, tc.Scope.Deny) { + t.Fatalf("got %+v, want %+v", s, *tc.Scope) + } + }) + } +} + +// contractCall is a tool call that may fail either way a client sees it: a +// tool result marked as an error, or a protocol error for a tool the server +// does not offer. +func contractCall(cs *sdk.ClientSession, name string, args map[string]any) (string, bool) { + res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args}) + if err != nil { + return err.Error(), true + } + var text strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*sdk.TextContent); ok { + text.WriteString(tc.Text) + } + } + return text.String(), res.IsError +} + +// matchContract checks every key the fixture names; arrays match element for +// element. +func matchContract(t *testing.T, actual, expected any, where string) { + t.Helper() + switch want := expected.(type) { + case []any: + got, ok := actual.([]any) + if !ok || len(got) != len(want) { + t.Errorf("%s: got %v, want %v", where, actual, expected) + return + } + for i := range want { + matchContract(t, got[i], want[i], fmt.Sprintf("%s[%d]", where, i)) + } + case map[string]any: + got, ok := actual.(map[string]any) + if !ok { + t.Errorf("%s: got %v, want an object", where, actual) + return + } + for key, value := range want { + matchContract(t, got[key], value, where+"."+key) + } + default: + if !reflect.DeepEqual(actual, expected) { + t.Errorf("%s: got %v, want %v", where, actual, expected) + } + } +} + +// vaultSpellings is a needle with $VAULT replaced by the vault root as a +// result can spell it: as is in an error message, and JSON-escaped inside a +// result object, where a Windows root's backslashes are doubled. +func vaultSpellings(needle, root string) []string { + escaped, _ := json.Marshal(root) + return []string{ + strings.ReplaceAll(needle, "$VAULT", root), + strings.ReplaceAll(needle, "$VAULT", string(escaped[1:len(escaped)-1])), + } +} + +func containsAny(text string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} + +func sortedCopy(values []string) []string { + out := append([]string{}, values...) + sort.Strings(out) + return out +} + +func TestSharedScopeContract(t *testing.T) { + contract := loadScopeContract(t) + for _, tc := range contract.Cases { + t.Run(tc.ID, func(t *testing.T) { + seed := contract.Vaults[tc.Vault] + base := t.TempDir() + root := filepath.Join(base, "vault") + write := func(dir string, files map[string]string) { + for rel, body := range files { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + } + write(root, seed.Files) + write(filepath.Join(base, "outside"), seed.Outside) + for rel, target := range seed.Symlinks { + if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + } + + var scope *Scope + if tc.Flags != nil { + s, err := NewScope(tc.Flags.Access, tc.Flags.ReadOnly, tc.Flags.Allow, tc.Flags.Deny) + if err != nil { + t.Fatal(err) + } + scope = &s + } + cs := runScoped(t, root, scope) + + if tc.Tools != nil { + listed := listedTools(t, cs) + names := []string{} + for name := range listed { + names = append(names, name) + } + sort.Strings(names) + if tc.Tools.Count != nil && len(names) != *tc.Tools.Count { + t.Errorf("lists %d tools, want %d: %v", len(names), *tc.Tools.Count, names) + } + if tc.Tools.Exactly != nil && !reflect.DeepEqual(names, sortedCopy(tc.Tools.Exactly)) { + t.Errorf("lists %v, want %v", names, sortedCopy(tc.Tools.Exactly)) + } + for _, name := range tc.Tools.Include { + if _, ok := listed[name]; !ok { + t.Errorf("%s is not listed", name) + } + } + for _, name := range tc.Tools.Absent { + if _, ok := listed[name]; ok { + t.Errorf("%s is listed", name) + } + } + for name, want := range tc.Tools.Annotations { + tool, ok := listed[name] + if !ok { + t.Errorf("%s is not listed", name) + continue + } + raw, _ := json.Marshal(tool.Annotations) + got := map[string]any{} + _ = json.Unmarshal(raw, &got) + if !reflect.DeepEqual(got, want) { + t.Errorf("%s annotations: got %v, want %v", name, got, want) + } + } + } + + for _, call := range tc.Calls { + argsJSON, _ := json.Marshal(call.Args) + where := call.Tool + " " + string(argsJSON) + text, failed := contractCall(cs, call.Tool, call.Args) + e := call.Expect + if e.NotOffered { + if !failed { + t.Errorf("%s: should not be offered, got %s", where, text) + } + continue + } + if e.Error != nil && (!failed || text != "Error: "+*e.Error) { + t.Errorf("%s:\n got %q\nwant %q", where, text, "Error: "+*e.Error) + } + if (e.OK || e.Paths != nil || e.Folders != nil || e.Match != nil || e.AbsentKeys != nil) && failed { + t.Errorf("%s failed: %s", where, text) + continue + } + if e.Paths != nil { + var notes []struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(text), ¬es); err != nil { + t.Errorf("%s: %v\n%s", where, err, text) + continue + } + got := []string{} + for _, n := range notes { + got = append(got, n.Path) + } + if got = sortedCopy(got); !reflect.DeepEqual(got, sortedCopy(*e.Paths)) { + t.Errorf("%s:\n got %v\nwant %v", where, got, sortedCopy(*e.Paths)) + } + } + if e.Folders != nil { + var folders []struct { + Folder string `json:"folder"` + Subpath string `json:"subpath"` + } + if err := json.Unmarshal([]byte(text), &folders); err != nil { + t.Errorf("%s: %v\n%s", where, err, text) + continue + } + got := []string{} + for _, f := range folders { + got = append(got, f.Folder+":"+f.Subpath) + } + if got = sortedCopy(got); !reflect.DeepEqual(got, sortedCopy(*e.Folders)) { + t.Errorf("%s:\n got %v\nwant %v", where, got, sortedCopy(*e.Folders)) + } + } + if e.Match != nil { + var actual, expected any + if err := json.Unmarshal([]byte(text), &actual); err != nil { + t.Errorf("%s: %v\n%s", where, err, text) + continue + } + _ = json.Unmarshal(e.Match, &expected) + matchContract(t, actual, expected, where) + } + if e.AbsentKeys != nil { + object := map[string]any{} + _ = json.Unmarshal([]byte(text), &object) + for _, key := range e.AbsentKeys { + if _, ok := object[key]; ok { + t.Errorf("%s: has %q", where, key) + } + } + } + for _, needle := range e.Contains { + if !containsAny(text, vaultSpellings(needle, root)) { + t.Errorf("%s: lacks %q in %s", where, needle, text) + } + } + for _, needle := range e.Excludes { + if containsAny(text, vaultSpellings(needle, root)) { + t.Errorf("%s: has %q in %s", where, needle, text) + } + } + } + + if tc.Disk != nil { + at := func(rel string) string { return filepath.Join(root, filepath.FromSlash(rel)) } + for _, rel := range tc.Disk.Exists { + if _, err := os.Lstat(at(rel)); err != nil { + t.Errorf("%s should exist: %v", rel, err) + } + } + for _, rel := range tc.Disk.Absent { + if _, err := os.Lstat(at(rel)); err == nil { + t.Errorf("%s should be absent", rel) + } + } + for rel, want := range tc.Disk.Equals { + got, err := os.ReadFile(at(rel)) + if err != nil || string(got) != want { + t.Errorf("%s: got %q (%v), want %q", rel, got, err, want) + } + } + } + }) + } +} diff --git a/internal/mcp/scope_test.go b/internal/mcp/scope_test.go new file mode 100644 index 0000000..ca14786 --- /dev/null +++ b/internal/mcp/scope_test.go @@ -0,0 +1,529 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ZenNotes/tui/internal/backend" +) + +// The scope rules of ZenNotes/tui#21 (docs/ideas/mcp-scoped-access.md), +// driven through the server the way a client reaches them. + +// seedScopedVault lays out a root-mode vault with notes the scope below +// shares, notes it hides, and the ways a hidden note could be reached +// anyway: a symlink from a shared folder, a case variant, the internal +// directory. +func seedScopedVault(t *testing.T) string { + t.Helper() + root := t.TempDir() + files := map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"root"}`, + "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n", + "Projects/Sub/Step.md": "# Step\n", + "Projects/Clients/Acme.md": "# Acme\n\nclient secret needle #private\n- [ ] invoice acme\n", + "Projects/diagram.png": "png", + "Areas/Health/Run.md": "# Run\n\nafter [[Plan]]\n", + "Areas/Money.md": "# Money\n\nneedle\n", + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n", + "archive/Projects/Old.md": "# Old\n", + "attachements/pic.png": "png", + "Journal/Many1.md": "needle\nneedle\nneedle\n", + "Journal/Many2.md": "needle\nneedle\nneedle\n", + "trash/Projects/Thrown.md": "# Thrown\n", + "Projects/Clients/Acme Two.md": "# Acme Two\n", + "Journal/Sub/Deep.md": "# Deep\n", + "Areas/Health/Clinic/Visit.md": "# Visit\n", + "Projects/Sub/More/Further.md": "# Further\n", + "Areas/Health/Clinic/Notes.md": "# Notes\n", + "Projects/Sub/More/Again.md": "# Again\n", + "Journal/Sub/Deeper/Bottom.md": "# Bottom\n", + "Areas/Health/Clinic/Bills.md": "# Bills\n", + "Projects/Sub/More/Last.md": "# Last\n", + "Journal/Sub/Deeper/Bottom2.md": "# Bottom2\n", + } + for rel, body := range files { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join("..", "Journal"), filepath.Join(root, "Projects", "inner")); err != nil { + t.Fatal(err) + } + return root +} + +func testScope(t *testing.T, access string, allow, deny []string) *Scope { + t.Helper() + s, err := NewScope(access, false, allow, deny) + if err != nil { + t.Fatal(err) + } + return &s +} + +// runScoped serves a vault under a scope over an in-memory transport. +func runScoped(t *testing.T, root string, scope *Scope) *sdk.ClientSession { + t.Helper() + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + ctx, cancel := context.WithCancel(context.Background()) + serverT, clientT := sdk.NewInMemoryTransports() + done := make(chan struct{}) + go func() { + defer close(done) + _ = Run(ctx, Options{ + ResolveTarget: func() (backend.Target, error) { return backend.Target{Kind: backend.KindLocal, Root: root}, nil }, + OpenBackend: func(t backend.Target) (Backend, error) { return backend.New(t, backend.Options{}) }, + Transport: serverT, + Scope: scope, + }) + }() + client := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "0"}, nil) + cs, err := client.Connect(ctx, clientT, nil) + if err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + _ = cs.Close() + cancel() + <-done + }) + return cs +} + +func mustCall(t *testing.T, cs *sdk.ClientSession, name string, args map[string]any) string { + t.Helper() + text, isErr := callTool(t, cs, name, args) + if isErr { + t.Fatalf("%s %v failed: %s", name, args, text) + } + return text +} + +func mustFail(t *testing.T, cs *sdk.ClientSession, name string, args map[string]any) string { + t.Helper() + text, isErr := callTool(t, cs, name, args) + if !isErr { + t.Fatalf("%s %v should fail, got: %s", name, args, text) + } + return text +} + +func notePaths(t *testing.T, text string) []string { + t.Helper() + var notes []struct { + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(text), ¬es); err != nil { + t.Fatalf("%v\n%s", err, text) + } + out := []string{} + for _, n := range notes { + out = append(out, n.Path) + } + sort.Strings(out) + return out +} + +func listedTools(t *testing.T, cs *sdk.ClientSession) map[string]*sdk.Tool { + t.Helper() + res, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + out := map[string]*sdk.Tool{} + for _, tool := range res.Tools { + out[tool.Name] = tool + } + return out +} + +func sharedScope(t *testing.T) *Scope { + // The issue's lowercase spelling finds `Projects/`. + return testScope(t, "write", []string{"/projects", "Areas/Health"}, []string{"Projects/Clients"}) +} + +func TestScopeFlagsRefuseWhatCouldNeverBeShared(t *testing.T) { + for _, bad := range []string{".zennotes", "Projects/../Journal", "./", "a/.hidden/b", ""} { + if _, err := NewScope("", false, []string{bad}, nil); err == nil { + t.Errorf("--allow-path %q should be refused", bad) + } + } + if _, err := NewScope("admin", false, nil, nil); err == nil { + t.Error("--access admin should be refused") + } + s, err := NewScope("write", true, []string{"/Projects/", `Areas\Health`, "Projects"}, nil) + if err != nil { + t.Fatal(err) + } + if s.Access != AccessRead || !reflect.DeepEqual(s.Allow, []string{"Projects", "Areas/Health"}) { + t.Fatalf("got %+v", s) + } +} + +func TestEveryToolIsClassified(t *testing.T) { + for _, tool := range tools() { + if _, ok := toolClasses[tool.name]; !ok { + t.Errorf("%s has no access class in toolClasses", tool.name) + } + } +} + +func TestScopedListingsShowOnlyWhatIsShared(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, sharedScope(t)) + + got := notePaths(t, mustCall(t, cs, "list_notes", map[string]any{})) + want := []string{ + "Areas/Health/Clinic/Bills.md", "Areas/Health/Clinic/Notes.md", "Areas/Health/Clinic/Visit.md", "Areas/Health/Run.md", + "Projects/Plan.md", "Projects/Sub/More/Again.md", "Projects/Sub/More/Further.md", "Projects/Sub/More/Last.md", "Projects/Sub/Step.md", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("list_notes:\n got %v\nwant %v", got, want) + } + if trash := notePaths(t, mustCall(t, cs, "list_notes", map[string]any{"folder": "trash"})); len(trash) != 0 { + t.Fatalf("trash is not shared: %v", trash) + } + + var folders []struct { + Folder string `json:"folder"` + Subpath string `json:"subpath"` + } + if err := json.Unmarshal([]byte(mustCall(t, cs, "list_folders", map[string]any{})), &folders); err != nil { + t.Fatal(err) + } + names := []string{} + for _, f := range folders { + names = append(names, f.Folder+":"+f.Subpath) + } + sort.Strings(names) + wantFolders := []string{"inbox:Areas", "inbox:Areas/Health", "inbox:Areas/Health/Clinic", "inbox:Projects", "inbox:Projects/Sub", "inbox:Projects/Sub/More"} + if !reflect.DeepEqual(names, wantFolders) { + t.Fatalf("list_folders:\n got %v\nwant %v", names, wantFolders) + } + + tags := mustCall(t, cs, "list_tags", map[string]any{}) + if strings.Contains(tags, "private") || !strings.Contains(tags, "proj") { + t.Fatalf("list_tags: %s", tags) + } + tasks := mustCall(t, cs, "list_tasks", map[string]any{}) + if !strings.Contains(tasks, "ship it") || strings.Contains(tasks, "invoice") || strings.Contains(tasks, "mum") { + t.Fatalf("list_tasks: %s", tasks) + } + assets := mustCall(t, cs, "list_assets", map[string]any{}) + if !strings.Contains(assets, "Projects/diagram.png") || strings.Contains(assets, "attachements") { + t.Fatalf("list_assets: %s", assets) + } + if links := notePaths(t, mustCall(t, cs, "backlinks", map[string]any{"path": "Projects/Plan.md"})); !reflect.DeepEqual(links, []string{"Areas/Health/Run.md"}) { + t.Fatalf("backlinks: %v", links) + } + if hits := notePaths(t, mustCall(t, cs, "search_by_tag", map[string]any{"tag": "private"})); len(hits) != 0 { + t.Fatalf("search_by_tag: %v", hits) + } + if hits := notePaths(t, mustCall(t, cs, "search_by_title", map[string]any{"query": "diary"})); len(hits) != 0 { + t.Fatalf("search_by_title: %v", hits) + } +} + +// The walk stops at its limit, so filtering its first page would let hidden +// matches crowd out the shared one and say, by a short page, that they exist. +func TestScopedSearchFiltersBeforeItLimits(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, sharedScope(t)) + var matches []struct { + Path string `json:"path"` + LineText string `json:"lineText"` + } + if err := json.Unmarshal([]byte(mustCall(t, cs, "search_text", map[string]any{"query": "needle", "limit": 1})), &matches); err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].Path != "Projects/Plan.md" { + t.Fatalf("search_text: %+v", matches) + } +} + +// A hidden note answers exactly like a missing one, whichever way it is +// reached, and no error carries the vault's absolute path. +func TestHiddenNotesAreIndistinguishableFromMissingOnes(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, sharedScope(t)) + for _, p := range []string{ + "Journal/Diary.md", // outside allow + "journal/DIARY.md", // case variant of it + "Projects/inner/Diary.md", // through a symlink in a shared folder + "Projects/Clients/Acme.md", // denied inside allow + "projects/clients/ACME.md", // case variant of the denied note + ".zennotes/vault.json", // the internal directory + "Projects/../Journal/Diary.md", + "Journal/Missing.md", // does not exist at all + } { + got := mustFail(t, cs, "read_note", map[string]any{"path": p}) + if got != "Error: Note not found: "+p { + t.Errorf("read_note %q: %s", p, got) + } + if strings.Contains(got, root) { + t.Errorf("read_note %q leaks the vault path: %s", p, got) + } + } + body := mustCall(t, cs, "read_note", map[string]any{"path": "projects/plan.md"}) + if !strings.Contains(body, `"path": "Projects/Plan.md"`) { + t.Fatalf("a case variant of a shared note reads it under its own name: %s", body) + } + for _, call := range []struct { + tool string + args map[string]any + }{ + {"list_comments", map[string]any{"path": "Journal/Diary.md"}}, + {"backlinks", map[string]any{"path": "Journal/Diary.md"}}, + {"toggle_task", map[string]any{"id": "Journal/Diary.md#0"}}, + {"append_to_note", map[string]any{"path": "Journal/Diary.md", "text": "x"}}, + {"move_to_trash", map[string]any{"path": "Projects/Clients/Acme.md"}}, + } { + if got := mustFail(t, cs, call.tool, call.args); !strings.HasPrefix(got, "Error: Note not found: ") { + t.Errorf("%s: %s", call.tool, got) + } + } + diary, _ := os.ReadFile(filepath.Join(root, "Journal", "Diary.md")) + if string(diary) != "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n" { + t.Fatalf("a hidden note changed: %q", diary) + } +} + +func TestScopedWritesStayInsideTheScope(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, sharedScope(t)) + + created := mustCall(t, cs, "create_note", map[string]any{"folder": "inbox", "subpath": "projects/New", "title": "Idea"}) + if !strings.Contains(created, `"path": "Projects/New/Idea.md"`) { + t.Fatalf("create_note in a shared folder, in the vault's spelling: %s", created) + } + for _, sub := range []string{"", "Journal", "Areas", ".zennotes", "Attachements", "archive", "Projects/Clients", "Projects/../Journal", "Projects/Books.base"} { + got := mustFail(t, cs, "create_note", map[string]any{"folder": "inbox", "subpath": sub, "title": "Nope"}) + if !strings.Contains(got, "can create and move notes only inside: Projects, Areas/Health.") { + t.Errorf("create_note subpath %q: %s", sub, got) + } + } + mustFail(t, cs, "create_folder", map[string]any{"folder": "inbox", "subpath": "Journal/Planted"}) + if _, err := os.Stat(filepath.Join(root, "Journal", "Planted")); err == nil { + t.Fatal("create_folder made a folder in a hidden one") + } + mustFail(t, cs, "move_note", map[string]any{"path": "Projects/Plan.md", "targetFolder": "inbox", "targetSubpath": "Journal"}) + + // Renaming rewrites links in shared notes only: Run follows the new name, + // the hidden Diary keeps its old link rather than gain one the assistant + // chose. + mustCall(t, cs, "rename_note", map[string]any{"path": "Projects/Plan.md", "newTitle": "Plan B"}) + run, _ := os.ReadFile(filepath.Join(root, "Areas", "Health", "Run.md")) + diary, _ := os.ReadFile(filepath.Join(root, "Journal", "Diary.md")) + if !strings.Contains(string(run), "[[Plan B]]") || !strings.Contains(string(diary), "[[Plan]]") { + t.Fatalf("link rewrite:\nRun: %s\nDiary: %s", run, diary) + } + mustFail(t, cs, "rename_note", map[string]any{"path": "Projects/Clients/Acme Two.md", "newTitle": "Acme"}) + + // Folders: a folder holding something hidden is never moved or deleted + // as a whole, and the folder a scope names cannot move out of it. + if got := mustFail(t, cs, "delete_folder", map[string]any{"folder": "inbox", "subpath": "Projects", "confirm": true}); !strings.Contains(got, "cannot delete that folder") { + t.Fatalf("delete_folder Projects: %s", got) + } + if got := mustFail(t, cs, "delete_folder", map[string]any{"folder": "inbox", "subpath": "Journal", "confirm": true}); got != "Error: Folder not found: Journal" { + t.Fatalf("delete_folder Journal: %s", got) + } + mustFail(t, cs, "delete_folder", map[string]any{"folder": "inbox", "subpath": "attachements", "confirm": true}) + mustFail(t, cs, "rename_folder", map[string]any{"folder": "inbox", "oldSubpath": "Areas/Health", "newSubpath": "Health"}) + mustCall(t, cs, "rename_folder", map[string]any{"folder": "inbox", "oldSubpath": "Projects/Sub", "newSubpath": "Projects/Steps"}) + for _, kept := range []string{"Journal/Diary.md", "attachements/pic.png", "Projects/Clients/Acme.md", "Projects/Steps/Step.md"} { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(kept))); err != nil { + t.Errorf("%s: %v", kept, err) + } + } + + // Trashing is a lifecycle exit: allowed, and the note leaves the scope. + trashed := mustCall(t, cs, "move_to_trash", map[string]any{"path": "Projects/Steps/Step.md"}) + var meta struct { + Path string `json:"path"` + } + _ = json.Unmarshal([]byte(trashed), &meta) + mustFail(t, cs, "read_note", map[string]any{"path": meta.Path}) + mustFail(t, cs, "restore_from_trash", map[string]any{"path": meta.Path}) +} + +func TestScopeListsOnlyTheToolsItOffers(t *testing.T) { + root := seedScopedVault(t) + full := listedTools(t, runScoped(t, root, nil)) + if len(full) != len(tools()) { + t.Fatalf("full access lists %d of %d tools", len(full), len(tools())) + } + if !full["read_note"].Annotations.ReadOnlyHint || *full["delete_note"].Annotations.DestructiveHint != true || *full["append_to_note"].Annotations.DestructiveHint { + t.Fatal("annotations") + } + + read := runScoped(t, root, testScope(t, "read", nil, nil)) + listed := listedTools(t, read) + for name, tool := range listed { + if !tool.Annotations.ReadOnlyHint { + t.Errorf("read access lists %s", name) + } + } + if _, ok := listed["vault_info"]; !ok || len(listed) != 12 { + t.Fatalf("read access lists %d tools", len(listed)) + } + if _, err := read.CallTool(context.Background(), &sdk.CallToolParams{Name: "write_note", Arguments: map[string]any{"path": "Projects/Plan.md", "body": "gone"}}); err == nil { + t.Fatal("write_note must be unknown to a read-only client") + } + if body, _ := os.ReadFile(filepath.Join(root, "Projects", "Plan.md")); string(body) == "gone" { + t.Fatal("read-only client wrote a note") + } + + comment := listedTools(t, runScoped(t, root, testScope(t, "comment", nil, nil))) + if _, ok := comment["add_comment"]; !ok { + t.Fatal("comment access lists add_comment") + } + if _, ok := comment["toggle_task"]; ok { + t.Fatal("comment access must not list toggle_task") + } + + scoped := listedTools(t, runScoped(t, root, sharedScope(t))) + if _, ok := scoped["empty_trash"]; ok { + t.Fatal("empty_trash acts on every trashed note, so no path-restricted client gets it") + } + if _, ok := scoped["delete_note"]; !ok { + t.Fatal("write access keeps delete_note") + } +} + +func TestScopedVaultInfoNamesNoAbsolutePathsOrDenyEntries(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, testScope(t, "read", []string{"projects", "Areas/Gone"}, []string{"Projects/Clients"})) + text := mustCall(t, cs, "vault_info", map[string]any{}) + if strings.Contains(text, root) || strings.Contains(text, "vaultRoot") || strings.Contains(text, "Clients") || strings.Contains(text, "Journal") { + t.Fatalf("vault_info says too much: %s", text) + } + var info struct { + Access struct { + Level string `json:"level"` + Partial bool `json:"partial"` + Allow []string `json:"allow"` + MissingAllow []string `json:"missingAllow"` + } `json:"access"` + } + if err := json.Unmarshal([]byte(text), &info); err != nil { + t.Fatal(err) + } + if info.Access.Level != "read" || !info.Access.Partial || !reflect.DeepEqual(info.Access.Allow, []string{"Areas/Gone", "Projects"}) || !reflect.DeepEqual(info.Access.MissingAllow, []string{"Areas/Gone"}) { + t.Fatalf("access: %+v", info.Access) + } +} + +// A deny entry that matches nothing may have been renamed outside ZenNotes, +// so what it hid could sit under a new name: share nothing. +func TestAStaleDenyEntrySharesNothing(t *testing.T) { + root := seedScopedVault(t) + cs := runScoped(t, root, testScope(t, "read", nil, []string{"Diary Folder"})) + got := mustFail(t, cs, "list_notes", map[string]any{}) + if !strings.Contains(got, "nothing is shared") || strings.Contains(got, "Diary Folder") { + t.Fatalf("list_notes: %s", got) + } + info := mustCall(t, cs, "vault_info", map[string]any{}) + if !strings.Contains(info, `"blocked"`) { + t.Fatalf("vault_info should explain: %s", info) + } +} + +// A server rewrites inbound links in every note itself, so a scoped rename +// there would write into notes the client cannot see. +func TestScopedRenameNeedsALinkScopedBackend(t *testing.T) { + root := seedScopedVault(t) + local, err := backend.New(backend.Target{Kind: backend.KindLocal, Root: root}, backend.Options{}) + if err != nil { + t.Fatal(err) + } + opaque := struct{ Backend }{local} + _, err = CallToolScoped(context.Background(), "rename_note", map[string]any{"path": "Projects/Plan.md", "newTitle": "Other"}, opaque, sharedScope(t)) + if err == nil || !strings.Contains(err.Error(), "server rewrites links in every note") { + t.Fatalf("got %v", err) + } + if _, statErr := os.Stat(filepath.Join(root, "Projects", "Plan.md")); statErr != nil { + t.Fatal("the note must stay where it was") + } +} + +func TestScopedErrorsCarryNoAbsolutePath(t *testing.T) { + root := seedScopedVault(t) + local, _ := backend.New(backend.Target{Kind: backend.KindLocal, Root: root}, backend.Options{}) + scoped := newScopedBackend(local, *sharedScope(t)) + err := errors.New("open " + filepath.Join(root, "Projects", "Plan.md") + ": permission denied") + if got := DescribeToolError(err, scoped); strings.Contains(got, root) || !strings.Contains(got, "Projects") { + t.Fatalf("got %s", got) + } + if got := DescribeToolError(err, local); !strings.Contains(got, root) { + t.Fatal("full access keeps today's message") + } +} + +func TestScopeInstructionsSayWhatTheClientCanDo(t *testing.T) { + text := scopeInstructions(*testScope(t, "read", []string{"Projects"}, nil)) + for _, want := range []string{"do not guess", "read but not change", "vault_info"} { + if !strings.Contains(text, want) { + t.Errorf("instructions lack %q: %s", want, text) + } + } + if strings.Contains(scopeInstructions(*testScope(t, "comment", nil, nil)), "do not guess") { + t.Error("an access-only scope hides no paths") + } +} + +// Entries are paths as they exist on disk, so remapped system folders need no +// translation; archiving keeps the subpath, so `99 Archive/Projects` is where +// a shared folder's archived notes go. +func TestScopeFollowsRemappedSystemFolders(t *testing.T) { + root := t.TempDir() + for rel, body := range map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"inbox","systemFolderPaths":{"inbox":"01 Inbox","archive":"99 Archive"}}`, + "01 Inbox/Projects/A.md": "# A\n", + "01 Inbox/Private/B.md": "# B\n", + "99 Archive/Projects/Old.md": "# Old\n", + "99 Archive/Private/Gone.md": "# Gone\n", + } { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + cs := runScoped(t, root, testScope(t, "write", []string{"01 Inbox/Projects", "99 Archive/Projects"}, nil)) + if got := notePaths(t, mustCall(t, cs, "list_notes", map[string]any{"folder": "archive"})); !reflect.DeepEqual(got, []string{"99 Archive/Projects/Old.md"}) { + t.Fatalf("archive: %v", got) + } + created := mustCall(t, cs, "create_note", map[string]any{"folder": "inbox", "subpath": "Projects", "title": "C"}) + if !strings.Contains(created, `"path": "01 Inbox/Projects/C.md"`) { + t.Fatalf("create_note: %s", created) + } + mustFail(t, cs, "create_note", map[string]any{"folder": "inbox", "subpath": "Private", "title": "D"}) + archived := mustCall(t, cs, "archive_note", map[string]any{"path": "01 Inbox/Projects/A.md"}) + if !strings.Contains(archived, `"path": "99 Archive/Projects/A.md"`) { + t.Fatalf("archive_note: %s", archived) + } + mustCall(t, cs, "unarchive_note", map[string]any{"path": "99 Archive/Projects/A.md"}) + mustFail(t, cs, "unarchive_note", map[string]any{"path": "99 Archive/Private/Gone.md"}) + + // With only the inbox side shared, archiving is still allowed, as an exit. + narrow := runScoped(t, root, testScope(t, "write", []string{"01 Inbox/Projects"}, nil)) + mustCall(t, narrow, "archive_note", map[string]any{"path": "01 Inbox/Projects/C.md"}) + mustFail(t, narrow, "read_note", map[string]any{"path": "99 Archive/Projects/C.md"}) +} diff --git a/internal/mcp/scoped.go b/internal/mcp/scoped.go new file mode 100644 index 0000000..73f9838 --- /dev/null +++ b/internal/mcp/scoped.go @@ -0,0 +1,862 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "sync" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/database" + "github.com/ZenNotes/tui/internal/search" + "github.com/ZenNotes/tui/internal/vault" +) + +// scopedBackend is a Backend that shows a client only what its scope allows. +// Every tool runs against one, so no handler can skip the check, and a new +// handler written against Backend gets it for free. +// +// A path is resolved against the vault's own listings before the scope +// check, never trusted as given: the listings walk real directories and +// regular files only, so a path through a symlink, a case variant of a hidden +// folder on a case-insensitive volume, or a hidden directory simply has no +// match. Whatever the client is never shown does not exist for it, and the +// error for a hidden path is the same bytes as for a missing one. +// +// One is made per tool call; the listings it reads are cached for that call. +type scopedBackend struct { + inner Backend + scope Scope + + once sync.Once + view *scopeView + err error +} + +func newScopedBackend(inner Backend, scope Scope) *scopedBackend { + return &scopedBackend{inner: inner, scope: scope} +} + +// unwrapBackend returns the backend a scoped one wraps, so error reporting +// can still recognise a server. +func unwrapBackend(b Backend) Backend { + if sb, ok := b.(*scopedBackend); ok { + return sb.inner + } + return b +} + +// scopeView is the vault as one call sees it. +type scopeView struct { + paths map[string]string + primaryAtRoot bool + + // notes are the visible notes; byPath indexes every listed note. + notes []vault.NoteMeta + byPath map[string]vault.NoteMeta + // folders indexes every listed folder by its vault-relative directory; + // allFolders keeps the listing's order. + folders map[string]vault.FolderEntry + allFolders []vault.FolderEntry + // dirs are the folder paths plus the bucket directories, for spelling + // lookups. + dirs []string + + allow []string + deny []string + missingAllow []string + ancestors map[string]bool + + // root is the vault on this machine, where destinations are checked on + // disk; "" for a server's, whose walker is its own. + root string +} + +func notFound(rel string) error { return fmt.Errorf("Note not found: %s", rel) } +func folderNotFound(p string) error { return fmt.Errorf("Folder not found: %s", p) } + +var errNotOffered = errors.New("That is not available to an assistant with scoped access.") + +// staleDenyLogged keeps the stderr hint about a missing deny entry to one +// line per entry for the life of the process. +var staleDenyLogged sync.Map + +func (sb *scopedBackend) load(ctx context.Context) (*scopeView, error) { + sb.once.Do(func() { sb.view, sb.err = sb.build(ctx) }) + return sb.view, sb.err +} + +func (sb *scopedBackend) build(ctx context.Context) (*scopeView, error) { + settings, err := sb.inner.VaultSettings(ctx) + if err != nil { + return nil, err + } + desc, err := sb.inner.Describe(ctx) + if err != nil { + return nil, err + } + notes, err := sb.inner.ListNotes(ctx) + if err != nil { + return nil, err + } + folders, err := sb.inner.ListFolders(ctx) + if err != nil { + return nil, err + } + v := &scopeView{ + paths: settings.SystemFolderPaths, + primaryAtRoot: desc.PrimaryNotesLocation == vault.PrimaryNotesRoot, + byPath: map[string]vault.NoteMeta{}, + folders: map[string]vault.FolderEntry{}, + allFolders: folders, + ancestors: map[string]bool{}, + } + if sb.inner.Kind() == backend.KindLocal { + v.root = sb.inner.Root() + } + for _, f := range vault.AllFolders { + if dir := v.bucketDir(f); dir != "" { + v.dirs = append(v.dirs, dir) + } + } + for _, f := range folders { + rel := v.folderRel(f) + v.folders[rel] = f + v.dirs = append(v.dirs, rel) + } + known := make([]string, 0, len(notes)+len(v.dirs)) + for _, n := range notes { + v.byPath[n.Path] = n + known = append(known, n.Path) + } + known = append(known, v.dirs...) + + // Entries take the vault's own spelling, so `--allow-path /projects` + // finds `Projects/`. + for _, entry := range sb.scope.Allow { + canonical, ok := spell(entry, known) + if !ok { + v.missingAllow = append(v.missingAllow, entry) + canonical = entry + } + v.allow = append(v.allow, canonical) + for p := path.Dir(canonical); p != "." && p != "/"; p = path.Dir(p) { + v.ancestors[p] = true + } + } + // A deny entry that matches nothing was renamed or deleted outside + // ZenNotes, or belongs to another vault: what it hid may now sit under a + // new name, so the scope shares nothing until it is fixed. Only stderr, + // which lands in the client's logs and not in front of the model, names + // the entry. + for _, entry := range sb.scope.Deny { + matched := false + for _, p := range known { + if search.Fold(p) == search.Fold(entry) { + matched = true + break + } + } + if !matched { + if _, seen := staleDenyLogged.LoadOrStore(entry, true); !seen { + fmt.Fprintf(os.Stderr, "zn mcp: --deny-path %s matches nothing in this vault, so the MCP server shares nothing until it is fixed\n", entry) + } + return nil, errors.New("This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed.") + } + v.deny = append(v.deny, entry) + } + for _, n := range notes { + if v.visible(n.Path) { + v.notes = append(v.notes, n) + } + } + return v, nil +} + +// spell finds the vault's spelling of p among known paths: exact, else the +// one path equal to it after case and accent folding. +func spell(p string, known []string) (string, bool) { + folded := search.Fold(p) + match := "" + count := 0 + for _, k := range known { + if k == p { + return k, true + } + if search.Fold(k) == folded { + match = k + count++ + } + } + if count == 1 { + return match, true + } + return "", false +} + +func (v *scopeView) bucketDir(f vault.NoteFolder) string { + if f == vault.FolderInbox && v.primaryAtRoot { + return "" + } + return vault.ResolveFolderPath(f, v.paths) +} + +func joinRel(dir, sub string) string { + switch { + case dir == "": + return sub + case sub == "": + return dir + } + return dir + "/" + sub +} + +func (v *scopeView) folderRel(f vault.FolderEntry) string { + return joinRel(v.bucketDir(f.Folder), f.Subpath) +} + +func (v *scopeView) covers(p string) bool { + if len(v.allow) == 0 { + return true + } + for _, e := range v.allow { + if inside(p, e) { + return true + } + } + return false +} + +func (v *scopeView) denied(p string) bool { + for _, e := range v.deny { + if insideFolded(p, e) { + return true + } + } + return false +} + +func (v *scopeView) visible(p string) bool { return v.covers(p) && !v.denied(p) } + +// denyInside says whether a deny entry lies at or below p. +func (v *scopeView) denyInside(p string) bool { + for _, e := range v.deny { + if insideFolded(e, p) { + return true + } + } + return false +} + +// cleanRequest turns a client's path into vault-relative POSIX form, or +// refuses it: `.` and `..` segments and hidden names are never resolved. +func cleanRequest(raw string) (string, bool) { + p := strings.ReplaceAll(raw, "\\", "/") + parts := []string{} + for _, seg := range strings.Split(p, "/") { + if seg == "" { + continue + } + if seg == "." || seg == ".." || strings.HasPrefix(seg, ".") || strings.ContainsRune(seg, 0) { + return "", false + } + parts = append(parts, seg) + } + if len(parts) == 0 { + return "", false + } + return strings.Join(parts, "/"), true +} + +// note resolves a client's note path to a visible listed note. +func (v *scopeView) note(raw string) (vault.NoteMeta, bool) { + clean, ok := cleanRequest(raw) + if !ok { + return vault.NoteMeta{}, false + } + meta, ok := v.byPath[clean] + if !ok { + folded := search.Fold(clean) + count := 0 + for p, m := range v.byPath { + if search.Fold(p) == folded { + meta = m + count++ + } + } + if count != 1 { + return vault.NoteMeta{}, false + } + } + if !v.visible(meta.Path) { + return vault.NoteMeta{}, false + } + return meta, true +} + +// destination resolves where a note or folder would go, as the folder plus +// its subpath in the vault's spelling, or refuses. The directory must sit in +// the bucket asked for (a subpath never reaches another system folder or a +// hidden one) and inside an allow entry, not merely above one. +func (v *scopeView) destination(folder vault.NoteFolder, rawSub string) (string, error) { + sub := strings.Trim(strings.ReplaceAll(rawSub, "\\", "/"), "/") + if sub != "" { + clean, ok := cleanRequest(sub) + if !ok { + return "", v.createRefusal() + } + sub = clean + } + for _, seg := range strings.Split(sub, "/") { + // A database folder's insides are its record pages, not notes. + if strings.HasSuffix(strings.ToLower(seg), ".base") { + return "", v.createRefusal() + } + } + bucket := v.bucketDir(folder) + if bucket == "" && sub != "" && vault.IsReservedRootName(strings.SplitN(sub, "/", 2)[0], v.paths) { + return "", v.createRefusal() + } + rel := joinRel(bucket, sub) + if rel != "" { + if got, ok := vault.FolderForRelativePath(rel+"/x.md", v.paths); !ok || got != folder { + return "", v.createRefusal() + } + } + // Existing folders keep their spelling; new segments are created as given. + spelled := bucket + firstNew := "" + if sub != "" { + for _, seg := range strings.Split(sub, "/") { + next := joinRel(spelled, seg) + if s, ok := spell(next, v.dirs); ok { + next = s + } else if firstNew == "" { + firstNew = next + } + spelled = next + } + } + if !v.visible(spelled) { + return "", v.createRefusal() + } + // A segment the listings do not know is new only when nothing is there. + // The walks skip symbolic links, so `Projects/inner -> ../Journal` reads + // as missing, and creating through it would write into the hidden folder. + if firstNew != "" && v.root != "" { + if _, err := os.Lstat(filepath.Join(v.root, filepath.FromSlash(firstNew))); !errors.Is(err, fs.ErrNotExist) { + return "", v.createRefusal() + } + } + if bucket == "" { + return spelled, nil + } + return strings.TrimPrefix(strings.TrimPrefix(spelled, bucket), "/"), nil +} + +// createRefusal is the one answer for every destination a client may not +// use, hidden or not, so it confirms nothing about hidden folders. +func (v *scopeView) createRefusal() error { + if len(v.allow) > 0 { + return fmt.Errorf("This assistant can create and move notes only inside: %s.", strings.Join(v.allow, ", ")) + } + return errors.New("This assistant cannot put notes there.") +} + +// folder resolves a client's (bucket, subpath) to a visible listed folder. +func (v *scopeView) folder(folder vault.NoteFolder, rawSub string) (string, vault.FolderEntry, bool) { + clean, ok := cleanRequest(rawSub) + if !ok { + return "", vault.FolderEntry{}, false + } + rel, ok := spell(joinRel(v.bucketDir(folder), clean), v.dirs) + if !ok { + return "", vault.FolderEntry{}, false + } + entry, listed := v.folders[rel] + if !listed || entry.Folder != folder || !v.visible(rel) { + return "", vault.FolderEntry{}, false + } + return rel, entry, true +} + +func (sb *scopedBackend) need(a Access) error { + if sb.scope.Access < a { + return accessError(sb.scope) + } + return nil +} + +// noteFor loads the view and resolves a note, the first step of every +// per-note operation. +func (sb *scopedBackend) noteFor(ctx context.Context, rel string, a Access) (vault.NoteMeta, error) { + if err := sb.need(a); err != nil { + return vault.NoteMeta{}, err + } + v, err := sb.load(ctx) + if err != nil { + return vault.NoteMeta{}, err + } + meta, ok := v.note(rel) + if !ok { + return vault.NoteMeta{}, notFound(rel) + } + return meta, nil +} + +// --- Backend --- + +func (sb *scopedBackend) Kind() backend.Kind { return sb.inner.Kind() } +func (sb *scopedBackend) Label() string { return sb.inner.Label() } +func (sb *scopedBackend) Root() string { return sb.inner.Root() } + +func (sb *scopedBackend) Describe(ctx context.Context) (backend.Description, error) { + return sb.inner.Describe(ctx) +} + +func (sb *scopedBackend) ListNotes(ctx context.Context) ([]vault.NoteMeta, error) { + v, err := sb.load(ctx) + if err != nil { + return nil, err + } + return append([]vault.NoteMeta{}, v.notes...), nil +} + +// ListFolders is the visible folders plus the folders above an allow entry, +// which are listed by name (the entry names them already) so the tree makes +// sense, while nothing directly inside them is visible. +func (sb *scopedBackend) ListFolders(ctx context.Context) ([]vault.FolderEntry, error) { + v, err := sb.load(ctx) + if err != nil { + return nil, err + } + out := []vault.FolderEntry{} + for _, f := range v.allFolders { + rel := v.folderRel(f) + if v.visible(rel) || (v.ancestors[rel] && !v.denied(rel)) { + out = append(out, f) + } + } + return out, nil +} + +func (sb *scopedBackend) ListAssets(ctx context.Context) ([]vault.AssetMeta, error) { + v, err := sb.load(ctx) + if err != nil { + return nil, err + } + all, err := sb.inner.ListAssets(ctx) + if err != nil { + return nil, err + } + out := []vault.AssetMeta{} + for _, a := range all { + if v.visible(a.Path) { + out = append(out, a) + } + } + return out, nil +} + +func (sb *scopedBackend) ReadAsset(context.Context, string) ([]byte, error) { + return nil, errNotOffered +} + +func (sb *scopedBackend) ReadNote(ctx context.Context, rel string) (vault.NoteContent, error) { + meta, err := sb.noteFor(ctx, rel, AccessRead) + if err != nil { + return vault.NoteContent{}, err + } + return sb.inner.ReadNote(ctx, meta.Path) +} + +func (sb *scopedBackend) WriteNote(ctx context.Context, rel, body string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.WriteNote(ctx, meta.Path, body) +} + +func (sb *scopedBackend) CreateNote(ctx context.Context, folder vault.NoteFolder, title, subpath string, body *string) (vault.NoteMeta, error) { + if err := sb.need(AccessWrite); err != nil { + return vault.NoteMeta{}, err + } + v, err := sb.load(ctx) + if err != nil { + return vault.NoteMeta{}, err + } + sub, err := v.destination(folder, subpath) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.CreateNote(ctx, folder, title, sub, body) +} + +func (sb *scopedBackend) AppendToNote(ctx context.Context, rel, text string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.AppendToNote(ctx, meta.Path, text) +} + +func (sb *scopedBackend) PrependToNote(ctx context.Context, rel, text string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.PrependToNote(ctx, meta.Path, text) +} + +// linkScopedRenamer is a backend that can limit a rename's link rewrite to +// some notes. A server rewrites inbound links in every note itself, so a +// rename there would write into notes the client cannot see. +type linkScopedRenamer interface { + RenameNoteRewritingLinksIn(ctx context.Context, rel, nextTitle string, rewrite func(path string) bool) (vault.NoteMeta, error) +} + +func (sb *scopedBackend) RenameNote(ctx context.Context, rel, nextTitle string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + v, _ := sb.load(ctx) + next := path.Join(path.Dir(meta.Path), vault.SanitizeTitle(nextTitle)+".md") + if !v.visible(next) { + return vault.NoteMeta{}, errors.New("This assistant cannot give the note that name.") + } + if !sb.scope.Restricted() { + return sb.inner.RenameNote(ctx, meta.Path, nextTitle) + } + renamer, ok := sb.inner.(linkScopedRenamer) + if !ok { + return vault.NoteMeta{}, errors.New("Renaming notes on a ZenNotes server is not available to an assistant that sees only part of the vault: the server rewrites links in every note, including ones this assistant cannot see.") + } + return renamer.RenameNoteRewritingLinksIn(ctx, meta.Path, nextTitle, v.visible) +} + +func (sb *scopedBackend) MoveNote(ctx context.Context, rel string, folder vault.NoteFolder, subpath string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + v, _ := sb.load(ctx) + sub, err := v.destination(folder, subpath) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.MoveNote(ctx, meta.Path, folder, sub) +} + +// Archiving and trashing are lifecycle exits: the note may leave what the +// client sees, and the user can undo either in the app. +func (sb *scopedBackend) ArchiveNote(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.ArchiveNote(ctx, meta.Path) +} + +func (sb *scopedBackend) MoveToTrash(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.MoveToTrash(ctx, meta.Path) +} + +// restoreTarget checks that a note brought back to the inbox lands where the +// client can see it: the same subpath under the inbox bucket. +func (sb *scopedBackend) restoreTarget(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + v, _ := sb.load(ctx) + sub := vault.FolderSubpathOf(meta.Path, meta.Folder, v.primaryAtRoot, v.paths) + if _, err := v.destination(vault.FolderInbox, sub); err != nil { + return vault.NoteMeta{}, err + } + return meta, nil +} + +func (sb *scopedBackend) UnarchiveNote(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.restoreTarget(ctx, rel) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.UnarchiveNote(ctx, meta.Path) +} + +func (sb *scopedBackend) RestoreFromTrash(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.restoreTarget(ctx, rel) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.RestoreFromTrash(ctx, meta.Path) +} + +func (sb *scopedBackend) DuplicateNote(ctx context.Context, rel string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.DuplicateNote(ctx, meta.Path) +} + +func (sb *scopedBackend) DeleteNote(ctx context.Context, rel string) error { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return err + } + return sb.inner.DeleteNote(ctx, meta.Path) +} + +func (sb *scopedBackend) EmptyTrash(ctx context.Context) error { + if err := sb.need(AccessWrite); err != nil { + return err + } + if sb.scope.Restricted() { + return errNotOffered + } + return sb.inner.EmptyTrash(ctx) +} + +func (sb *scopedBackend) InsertAtLine(ctx context.Context, rel string, lineNumber int, text string) (vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, err + } + return sb.inner.InsertAtLine(ctx, meta.Path, lineNumber, text) +} + +func (sb *scopedBackend) ReplaceInNote(ctx context.Context, rel, find, replace string, all bool) (vault.NoteMeta, int, error) { + meta, err := sb.noteFor(ctx, rel, AccessWrite) + if err != nil { + return vault.NoteMeta{}, 0, err + } + return sb.inner.ReplaceInNote(ctx, meta.Path, find, replace, all) +} + +func (sb *scopedBackend) CreateFolder(ctx context.Context, folder vault.NoteFolder, subpath string) error { + if err := sb.need(AccessWrite); err != nil { + return err + } + v, err := sb.load(ctx) + if err != nil { + return err + } + sub, err := v.destination(folder, subpath) + if err != nil { + return err + } + if sub == "" { + return errors.New("Folder name is required") + } + return sb.inner.CreateFolder(ctx, folder, sub) +} + +// wholeFolder resolves a folder a client may rename or delete as a whole: it +// must be visible and hold nothing hidden, or the operation would carry or +// destroy what the client cannot see. The refusal for a folder that holds +// something hidden is the one bit this tells a client, documented in the +// design. +func (sb *scopedBackend) wholeFolder(ctx context.Context, folder vault.NoteFolder, subpath string, verb string) (*scopeView, string, error) { + if err := sb.need(AccessWrite); err != nil { + return nil, "", err + } + v, err := sb.load(ctx) + if err != nil { + return nil, "", err + } + rel, entry, ok := v.folder(folder, subpath) + if !ok { + return nil, "", folderNotFound(subpath) + } + if v.denyInside(rel) { + return nil, "", fmt.Errorf("This assistant cannot %s that folder.", verb) + } + return v, entry.Subpath, nil +} + +func (sb *scopedBackend) RenameFolder(ctx context.Context, folder vault.NoteFolder, oldSubpath, newSubpath string) (string, error) { + v, oldSub, err := sb.wholeFolder(ctx, folder, oldSubpath, "rename") + if err != nil { + return "", err + } + newSub, err := v.destination(folder, newSubpath) + if err != nil { + return "", err + } + if newSub == "" { + return "", errors.New("Both old and new folder paths are required") + } + return sb.inner.RenameFolder(ctx, folder, oldSub, newSub) +} + +func (sb *scopedBackend) DeleteFolder(ctx context.Context, folder vault.NoteFolder, subpath string) error { + _, sub, err := sb.wholeFolder(ctx, folder, subpath, "delete") + if err != nil { + return err + } + return sb.inner.DeleteFolder(ctx, folder, sub) +} + +// SearchText filters before it limits: the inner walk stops at its limit, so +// filtering its first page would let hidden matches crowd out visible ones, +// and a short page would say how many hidden notes match. +func (sb *scopedBackend) SearchText(ctx context.Context, query string, limit int) ([]vault.TextSearchMatch, error) { + v, err := sb.load(ctx) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = 80 + } + all, err := sb.inner.SearchText(ctx, query, int(^uint(0)>>1)) + if err != nil { + return nil, err + } + out := []vault.TextSearchMatch{} + for _, m := range all { + if len(out) >= limit { + break + } + if n, ok := v.byPath[m.Path]; ok && v.visible(n.Path) { + out = append(out, m) + } + } + return out, nil +} + +func (sb *scopedBackend) Backlinks(ctx context.Context, rel string) ([]vault.NoteMeta, error) { + meta, err := sb.noteFor(ctx, rel, AccessRead) + if err != nil { + return nil, err + } + v, _ := sb.load(ctx) + all, err := sb.inner.Backlinks(ctx, meta.Path) + if err != nil { + return nil, err + } + out := []vault.NoteMeta{} + for _, n := range all { + if v.visible(n.Path) { + out = append(out, n) + } + } + return out, nil +} + +func (sb *scopedBackend) filterTasks(v *scopeView, all []vault.Task) []vault.Task { + out := []vault.Task{} + for _, t := range all { + if _, listed := v.byPath[t.SourcePath]; listed && v.visible(t.SourcePath) { + out = append(out, t) + } + } + return out +} + +func (sb *scopedBackend) ScanTasks(ctx context.Context, opts vault.ParseTasksOptions) ([]vault.Task, error) { + v, err := sb.load(ctx) + if err != nil { + return nil, err + } + all, err := sb.inner.ScanTasks(ctx, opts) + if err != nil { + return nil, err + } + return sb.filterTasks(v, all), nil +} + +func (sb *scopedBackend) ScanTasksForPath(ctx context.Context, rel string, opts vault.ParseTasksOptions) ([]vault.Task, error) { + meta, err := sb.noteFor(ctx, rel, AccessRead) + if err != nil { + return nil, err + } + v, _ := sb.load(ctx) + all, err := sb.inner.ScanTasksForPath(ctx, meta.Path, opts) + if err != nil { + return nil, err + } + return sb.filterTasks(v, all), nil +} + +func (sb *scopedBackend) ToggleTask(ctx context.Context, taskID string, dialect vault.TaskDialect) (*vault.Task, error) { + hash := strings.LastIndex(taskID, "#") + if hash <= 0 { + return nil, fmt.Errorf("Task not found: %s", taskID) + } + meta, err := sb.noteFor(ctx, taskID[:hash], AccessWrite) + if err != nil { + return nil, err + } + return sb.inner.ToggleTask(ctx, meta.Path+taskID[hash:], dialect) +} + +// DatabaseOps is never reached through MCP; there is no database tool. +func (sb *scopedBackend) DatabaseOps() *database.Ops { return nil } + +func (sb *scopedBackend) ListComments(ctx context.Context, rel string) ([]vault.NoteComment, error) { + meta, err := sb.noteFor(ctx, rel, AccessRead) + if err != nil { + return nil, err + } + return sb.inner.ListComments(ctx, meta.Path) +} + +func (sb *scopedBackend) WriteComments(ctx context.Context, rel string, comments []vault.NoteComment) ([]vault.NoteComment, error) { + meta, err := sb.noteFor(ctx, rel, AccessComment) + if err != nil { + return nil, err + } + return sb.inner.WriteComments(ctx, meta.Path, comments) +} + +func (sb *scopedBackend) ListTemplates(context.Context) ([]vault.CustomTemplateFile, error) { + return nil, errNotOffered +} + +func (sb *scopedBackend) WriteTemplate(context.Context, vault.WriteTemplateInput) (vault.CustomTemplateFile, error) { + return vault.CustomTemplateFile{}, errNotOffered +} + +func (sb *scopedBackend) DeleteTemplate(context.Context, string) error { return errNotOffered } + +func (sb *scopedBackend) VaultSettings(ctx context.Context) (vault.VaultSettings, error) { + return sb.inner.VaultSettings(ctx) +} + +func (sb *scopedBackend) UpdateVaultSettings(context.Context, func(raw map[string]any)) error { + return errNotOffered +} + +// accessInfo is the scope as vault_info reports it, with entries in the +// vault's spelling. +func (sb *scopedBackend) accessInfo(ctx context.Context) orderedResult { + v, err := sb.load(ctx) + if err != nil { + return describeScope(sb.scope, sb.scope.Allow, nil, err) + } + return describeScope(sb.scope, v.allow, v.missingAllow, nil) +} + +// scrubPaths keeps the vault's absolute location out of a scoped client's +// errors: an MCP-only client has no use for it, and it names the user's home +// directory. +func scrubPaths(msg string, b Backend) string { + root := b.Root() + if root == "" { + return msg + } + msg = strings.ReplaceAll(msg, root+string(os.PathSeparator), "") + return strings.ReplaceAll(msg, root, "the vault") +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 434aa45..438887d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -34,6 +34,10 @@ type Options struct { ResolveTarget func() (backend.Target, error) // OpenBackend binds a resolved target. OpenBackend func(backend.Target) (Backend, error) + // Scope limits what clients see and change (ZenNotes/tui#21); nil is + // full access. It comes from the server's own launch flags, never from a + // tool call. + Scope *Scope // Version is reported to clients. Version string // Transport defaults to stdio; tests pass an in-memory one. @@ -156,9 +160,15 @@ func tools() []toolDef { } folders, err := b.ListFolders(ctx) if err != nil { - return nil, err + // A scope that shares nothing still answers vault_info, with + // the reason under access.blocked. + if _, scoped := b.(*scopedBackend); !scoped { + return nil, err + } + folders = []vault.FolderEntry{} } isRoot := description.PrimaryNotesLocation == vault.PrimaryNotesRoot + scoped, isScoped := b.(*scopedBackend) pathNotes := "IMPORTANT: Always use the `path` returned by other tools verbatim. Never prepend `inbox/` to a path you got back from list_notes / create_note / read_note. " switchNote := "" if previous, ok := ctx.Value(previousVaultKey{}).(backend.Target); ok { @@ -169,7 +179,30 @@ func tools() []toolDef { } else { pathNotes += "This vault uses INBOX mode: notes for the conceptual `inbox` folder live under `inbox/` (e.g. `inbox/MyNote.md`)." } + if isScoped && scoped.scope.Restricted() { + pathNotes += " You see part of this vault: `access.allow` lists what was shared (empty means everything except some hidden paths). Anything else does not exist for you." + } top := []string{"inbox", "quick", "archive", "trash"} + if isScoped { + // An assistant with scoped access gets no absolute paths: it + // has no use for them, and they name the user's home folder. + result := orderedResult{{"kind", string(description.Kind)}} + if description.Kind == backend.KindRemote { + var serverName *string + if description.Name != "" { + serverName = &description.Name + } + result = append(result, orderedResult{{"server", description.BaseURL}, {"serverName", serverName}}...) + } + result = append(result, orderedResult{ + {"primaryNotesLocation", description.PrimaryNotesLocation}, + {"topFolders", top}, + {"subfolders", folders}, + {"access", scoped.accessInfo(ctx)}, + {"notes", switchNote + pathNotes}, + }...) + return result, nil + } if description.Kind == backend.KindRemote { notes := "This vault lives on a self-hosted ZenNotes server, the workspace the desktop app currently has open. Every tool works on it through the server API; paths are vault-relative POSIX paths exactly as the server reports them. " + pathNotes if !description.AuthConfigured { @@ -992,14 +1025,32 @@ func ToolNames() []string { // CallTool runs one tool by name: the seam tests use; the stdio server goes // through the same handlers. func CallTool(ctx context.Context, name string, a map[string]any, b Backend) (any, error) { + return CallToolScoped(ctx, name, a, b, nil) +} + +// CallToolScoped is CallTool under a scope, as the stdio server runs it: a +// tool the scope does not offer is unknown, and the rest see the vault +// through the scope. +func CallToolScoped(ctx context.Context, name string, a map[string]any, b Backend, scope *Scope) (any, error) { + if !toolAllowed(name, scope) { + return nil, fmt.Errorf("Unknown tool: %s", name) + } for _, d := range tools() { if d.name == name { - return d.handler(ctx, args(a), b) + return d.handler(ctx, args(a), scopeBackend(b, scope)) } } return nil, fmt.Errorf("Unknown tool: %s", name) } +// scopeBackend wraps a call's backend in its scope; full access runs bare. +func scopeBackend(b Backend, scope *Scope) Backend { + if scope == nil || !scope.Active() { + return b + } + return newScopedBackend(b, *scope) +} + // DescribeToolError phrases a failure for the agent. A server that turns // the request away is the one failure an agent cannot fix by retrying: the // desktop app keeps its token in the OS secret store, where zn cannot read @@ -1007,6 +1058,12 @@ func CallTool(ctx context.Context, name string, a map[string]any, b Backend) (an // against, nil when none resolved. func DescribeToolError(err error, b Backend) string { msg := err.Error() + if b != nil { + if _, scoped := b.(*scopedBackend); scoped { + msg = scrubPaths(msg, b) + } + b = unwrapBackend(b) + } if status := remote.StatusOf(err); status == 401 || status == 403 { serverURL := "" if r, ok := b.(*backend.Remote); ok { @@ -1060,16 +1117,26 @@ func Run(ctx context.Context, opts Options) error { } vaults := &session{resolve: opts.ResolveTarget, open: opts.OpenBackend} + instructions := ResolveInstructions() + if opts.Scope != nil && opts.Scope.Active() { + instructions += scopeInstructions(*opts.Scope) + } server := sdk.NewServer(&sdk.Implementation{Name: "zennotes", Version: version}, &sdk.ServerOptions{ - Instructions: ResolveInstructions(), + Instructions: instructions, Capabilities: &sdk.ServerCapabilities{Tools: &sdk.ToolCapabilities{}}, }) for _, def := range tools() { def := def + // A tool the scope does not offer is not listed, so the model never + // plans around it, and an SDK call to it is an unknown tool. + if !toolAllowed(def.name, opts.Scope) { + continue + } server.AddTool(&sdk.Tool{ Name: def.name, Description: def.description, InputSchema: json.RawMessage(def.schema), + Annotations: toolAnnotations(def.name), }, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { a := map[string]any{} if len(req.Params.Arguments) > 0 { @@ -1084,6 +1151,7 @@ func Run(ctx context.Context, opts Options) error { if previous != nil { ctx = context.WithValue(ctx, previousVaultKey{}, *previous) } + b = scopeBackend(b, opts.Scope) // An edit to a note is recorded so the user can review it in the app. agent := "Assistant" if req.Session != nil { diff --git a/internal/mcp/testdata/mcp-scope.json b/internal/mcp/testdata/mcp-scope.json new file mode 100644 index 0000000..0b510a6 --- /dev/null +++ b/internal/mcp/testdata/mcp-scope.json @@ -0,0 +1,543 @@ +{ + "schemaVersion": 1, + "vaults": { + "root": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"root\"}", + "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n", + "Projects/Sub/Step.md": "# Step\n", + "Projects/Sub/More/Further.md": "# Further\n", + "Projects/Sub/More/Again.md": "# Again\n", + "Projects/Sub/More/Last.md": "# Last\n", + "Projects/Clients/Acme.md": "# Acme\n\nclient secret needle #private\n- [ ] invoice acme\n", + "Projects/Clients/Acme Two.md": "# Acme Two\n", + "Projects/diagram.png": "png", + "Areas/Health/Run.md": "# Run\n\nafter [[Plan]]\n", + "Areas/Health/Clinic/Visit.md": "# Visit\n", + "Areas/Health/Clinic/Notes.md": "# Notes\n", + "Areas/Health/Clinic/Bills.md": "# Bills\n", + "Areas/Money.md": "# Money\n\nneedle\n", + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n", + "Journal/Many1.md": "needle\nneedle\nneedle\n", + "Journal/Many2.md": "needle\nneedle\nneedle\n", + "Journal/Sub/Deep.md": "# Deep\n", + "Journal/Sub/Deeper/Bottom.md": "# Bottom\n", + "Journal/Sub/Deeper/Bottom2.md": "# Bottom2\n", + "archive/Projects/Old.md": "# Old\n", + "trash/Projects/Thrown.md": "# Thrown\n", + "attachements/pic.png": "png" + }, + "symlinks": { + "Projects/inner": "../Journal", + "Projects/link": "../../outside" + }, + "outside": { + "secret.md": "# Secret\n\nneedle outside the vault\n" + } + }, + "accents": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"root\"}", + "Café/Menu.md": "# Menu\n\ncroissant\n", + "Cafeteria/Lunch.md": "# Lunch\n", + "Notes/Ideas.md": "# Ideas\n" + } + }, + "remapped": { + "files": { + ".zennotes/vault.json": "{\"primaryNotesLocation\":\"inbox\",\"systemFolderPaths\":{\"inbox\":\"01 Inbox\",\"archive\":\"99 Archive\"}}", + "01 Inbox/Projects/A.md": "# A\n", + "01 Inbox/Private/B.md": "# B\n", + "99 Archive/Projects/Old.md": "# Old\n", + "99 Archive/Private/Gone.md": "# Gone\n" + } + } + }, + "flagCases": [ + { + "id": "entries-are-cleaned-and-read-only-wins", + "flags": { "access": "write", "readOnly": true, "allow": ["/Projects/", "Areas\\Health", "Projects"] }, + "scope": { "access": "read", "allow": ["Projects", "Areas/Health"], "deny": [] } + }, + { + "id": "access-spellings", + "flags": { "access": " Edit ", "deny": ["Journal"] }, + "scope": { "access": "write", "allow": [], "deny": ["Journal"] } + }, + { + "id": "comment-access", + "flags": { "access": "comment" }, + "scope": { "access": "comment", "allow": [], "deny": [] } + }, + { + "id": "hidden-entry", + "flags": { "allow": [".zennotes"] }, + "error": "--allow-path \".zennotes\" names a hidden folder, which an assistant never sees" + }, + { + "id": "hidden-segment-in-deny", + "flags": { "deny": ["a/.hidden/b"] }, + "error": "--deny-path \"a/.hidden/b\" names a hidden folder, which an assistant never sees" + }, + { + "id": "traversal", + "flags": { "allow": ["Projects/../Journal"] }, + "error": "--allow-path \"Projects/../Journal\": . and .. are not allowed; name a folder or note inside the vault" + }, + { + "id": "dot-slash", + "flags": { "allow": ["./"] }, + "error": "--allow-path \"./\": . and .. are not allowed; name a folder or note inside the vault" + }, + { + "id": "empty-entry", + "flags": { "allow": [""] }, + "error": "--allow-path \"\" is empty; name a folder or note inside the vault" + }, + { + "id": "slash-only-entry", + "flags": { "deny": ["/"] }, + "error": "--deny-path \"/\" is empty; name a folder or note inside the vault" + }, + { + "id": "nul-in-entry", + "flags": { "allow": ["Projects\u0000x"] }, + "error": "--allow-path \"Projects\\x00x\" is not a valid path" + }, + { + "id": "unknown-access", + "flags": { "access": "admin" }, + "error": "--access must be read, comment or write, not \"admin\"" + } + ], + "cases": [ + { + "id": "listings-show-only-what-is-shared", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "tools": { "count": 33, "absent": ["empty_trash"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "paths": [ + "Areas/Health/Clinic/Bills.md", + "Areas/Health/Clinic/Notes.md", + "Areas/Health/Clinic/Visit.md", + "Areas/Health/Run.md", + "Projects/Plan.md", + "Projects/Sub/More/Again.md", + "Projects/Sub/More/Further.md", + "Projects/Sub/More/Last.md", + "Projects/Sub/Step.md" + ] + } + }, + { "tool": "list_notes", "args": { "folder": "trash" }, "expect": { "paths": [] } }, + { + "tool": "list_folders", + "args": {}, + "expect": { + "folders": [ + "inbox:Areas", + "inbox:Areas/Health", + "inbox:Areas/Health/Clinic", + "inbox:Projects", + "inbox:Projects/Sub", + "inbox:Projects/Sub/More" + ] + } + }, + { "tool": "list_tags", "args": {}, "expect": { "contains": ["proj"], "excludes": ["private"] } }, + { "tool": "list_tasks", "args": {}, "expect": { "contains": ["ship it"], "excludes": ["invoice", "mum"] } }, + { "tool": "list_assets", "args": {}, "expect": { "excludes": ["attachements", "pic.png"] } }, + { "tool": "backlinks", "args": { "path": "Projects/Plan.md" }, "expect": { "paths": ["Areas/Health/Run.md"] } }, + { "tool": "search_by_tag", "args": { "tag": "private" }, "expect": { "paths": [] } }, + { "tool": "search_by_title", "args": { "query": "diary" }, "expect": { "paths": [] } }, + { "tool": "search_by_title", "args": { "query": "plan" }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "needle", "limit": 1 }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "needle" }, "expect": { "paths": ["Projects/Plan.md"] } }, + { "tool": "search_text", "args": { "query": "outside the vault" }, "expect": { "paths": [] } } + ] + }, + { + "id": "a-hidden-note-answers-like-a-missing-one", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "calls": [ + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "read_note", "args": { "path": "journal/DIARY.md" }, "expect": { "error": "Note not found: journal/DIARY.md" } }, + { "tool": "read_note", "args": { "path": "Projects/inner/Diary.md" }, "expect": { "error": "Note not found: Projects/inner/Diary.md" } }, + { "tool": "read_note", "args": { "path": "Projects/link/secret.md" }, "expect": { "error": "Note not found: Projects/link/secret.md" } }, + { "tool": "read_note", "args": { "path": "Projects/Clients/Acme.md" }, "expect": { "error": "Note not found: Projects/Clients/Acme.md" } }, + { "tool": "read_note", "args": { "path": "projects/clients/ACME.md" }, "expect": { "error": "Note not found: projects/clients/ACME.md" } }, + { "tool": "read_note", "args": { "path": ".zennotes/vault.json" }, "expect": { "error": "Note not found: .zennotes/vault.json" } }, + { "tool": "read_note", "args": { "path": "Projects/../Journal/Diary.md" }, "expect": { "error": "Note not found: Projects/../Journal/Diary.md" } }, + { "tool": "read_note", "args": { "path": "Journal/Missing.md" }, "expect": { "error": "Note not found: Journal/Missing.md" } }, + { "tool": "read_note", "args": { "path": "Areas/Money.md" }, "expect": { "error": "Note not found: Areas/Money.md" } }, + { "tool": "read_note", "args": { "path": "archive/Projects/Old.md" }, "expect": { "error": "Note not found: archive/Projects/Old.md" } }, + { "tool": "read_note", "args": { "path": "attachements/pic.png" }, "expect": { "error": "Note not found: attachements/pic.png" } }, + { "tool": "read_note", "args": { "path": "Projects/diagram.png" }, "expect": { "error": "Note not found: Projects/diagram.png" } }, + { "tool": "read_note", "args": { "path": "projects/plan.md" }, "expect": { "match": { "path": "Projects/Plan.md" } } }, + { "tool": "list_comments", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "backlinks", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "toggle_task", "args": { "id": "Journal/Diary.md#0" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "append_to_note", "args": { "path": "Journal/Diary.md", "text": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "write_note", "args": { "path": "Projects/inner/Diary.md", "body": "gone" }, "expect": { "error": "Note not found: Projects/inner/Diary.md" } }, + { "tool": "add_comment", "args": { "path": "Journal/Diary.md", "body": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "move_to_trash", "args": { "path": "Projects/Clients/Acme.md" }, "expect": { "error": "Note not found: Projects/Clients/Acme.md" } }, + { "tool": "delete_note", "args": { "path": "Journal/Diary.md", "confirm": true }, "expect": { "error": "Note not found: Journal/Diary.md" } } + ], + "disk": { + "equals": { + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n", + "Projects/Clients/Acme.md": "# Acme\n\nclient secret needle #private\n- [ ] invoice acme\n" + } + } + }, + { + "id": "writes-stay-inside-the-scope", + "vault": "root", + "flags": { "access": "write", "allow": ["/projects", "Areas/Health"], "deny": ["Projects/Clients"] }, + "calls": [ + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "projects/New", "title": "Idea" }, "expect": { "match": { "path": "Projects/New/Idea.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Journal", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Areas", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": ".zennotes", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Attachements", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "archive", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Clients", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "projects/clients/Deeper", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/../Journal", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Books.base", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_note", "args": { "folder": "archive", "subpath": "Projects", "title": "Nope" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "create_folder", "args": { "folder": "inbox", "subpath": "Journal/Planted" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "move_note", "args": { "path": "Projects/Plan.md", "targetFolder": "inbox", "targetSubpath": "Journal" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "move_note", "args": { "path": "Projects/Sub/Step.md", "targetFolder": "archive", "targetSubpath": "Projects" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "rename_note", "args": { "path": "Projects/Plan.md", "newTitle": "Plan B" }, "expect": { "match": { "path": "Projects/Plan B.md" } } }, + { "tool": "rename_note", "args": { "path": "Projects/Clients/Acme Two.md", "newTitle": "Acme" }, "expect": { "error": "Note not found: Projects/Clients/Acme Two.md" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Projects", "confirm": true }, "expect": { "error": "This assistant cannot delete that folder." } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Journal", "confirm": true }, "expect": { "error": "Folder not found: Journal" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "attachements", "confirm": true }, "expect": { "error": "Folder not found: attachements" } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Projects/inner", "confirm": true }, "expect": { "error": "Folder not found: Projects/inner" } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Projects", "newSubpath": "Work" }, "expect": { "error": "This assistant cannot rename that folder." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Areas/Health", "newSubpath": "Health" }, "expect": { "error": "This assistant can create and move notes only inside: Projects, Areas/Health." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "projects/sub", "newSubpath": "Projects/Steps" }, "expect": { "ok": true } }, + { "tool": "move_to_trash", "args": { "path": "Projects/Steps/Step.md" }, "expect": { "match": { "path": "trash/Projects/Steps/Step.md", "folder": "trash" } } }, + { "tool": "read_note", "args": { "path": "trash/Projects/Steps/Step.md" }, "expect": { "error": "Note not found: trash/Projects/Steps/Step.md" } }, + { "tool": "restore_from_trash", "args": { "path": "trash/Projects/Steps/Step.md" }, "expect": { "error": "Note not found: trash/Projects/Steps/Step.md" } } + ], + "disk": { + "exists": [ + "Projects/New/Idea.md", + "Projects/Plan B.md", + "Projects/Steps/More/Last.md", + "trash/Projects/Steps/Step.md", + "Journal/Diary.md", + "Projects/Clients/Acme.md", + "Projects/Clients/Acme Two.md", + "attachements/pic.png" + ], + "absent": ["Projects/Plan.md", "Projects/Sub", "Journal/Planted", "Journal/Nope.md", "Nope.md"], + "equals": { + "Journal/Diary.md": "# Diary\n\nprivate needle #private\n\nabout [[Plan]]\n- [ ] call mum\n" + } + } + }, + { + "id": "nothing-is-created-through-a-link", + "vault": "root", + "flags": { "access": "write", "allow": ["Projects"] }, + "calls": [ + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/inner", "title": "ThroughLink" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/link", "title": "Out" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Plan.md", "title": "Under" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_folder", "args": { "folder": "inbox", "subpath": "Projects/inner/Made" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "move_note", "args": { "path": "Projects/Sub/Step.md", "targetFolder": "inbox", "targetSubpath": "Projects/inner" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "rename_folder", "args": { "folder": "inbox", "oldSubpath": "Projects/Sub", "newSubpath": "Projects/inner/Sub" }, "expect": { "error": "This assistant can create and move notes only inside: Projects." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects/Fresh/Deeper", "title": "New" }, "expect": { "match": { "path": "Projects/Fresh/Deeper/New.md" } } } + ], + "disk": { + "exists": ["Projects/Sub/Step.md", "Projects/Fresh/Deeper/New.md"], + "absent": ["Journal/ThroughLink.md", "Journal/Made", "Journal/Step.md", "Journal/Sub/Step.md", "Projects/Plan.md/Under.md"] + } + }, + { + "id": "read-access-lists-only-read-tools", + "vault": "root", + "flags": { "readOnly": true }, + "tools": { + "exactly": [ + "backlinks", + "list_assets", + "list_comments", + "list_folders", + "list_notes", + "list_tags", + "list_tasks", + "read_note", + "search_by_tag", + "search_by_title", + "search_text", + "vault_info" + ], + "annotations": { + "read_note": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false }, + "vault_info": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "write_note", "args": { "path": "Projects/Plan.md", "body": "gone" }, "expect": { "notOffered": true } }, + { "tool": "add_comment", "args": { "path": "Projects/Plan.md", "body": "x" }, "expect": { "notOffered": true } }, + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "match": { "path": "Journal/Diary.md" } } } + ], + "disk": { + "equals": { "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n" }, + "absent": [".zennotes/comments"] + } + }, + { + "id": "comment-access-adds-the-comment-tools", + "vault": "root", + "flags": { "access": "comment", "allow": ["Projects"] }, + "tools": { + "exactly": [ + "add_comment", + "backlinks", + "list_assets", + "list_comments", + "list_folders", + "list_notes", + "list_tags", + "list_tasks", + "read_note", + "reply_to_comment", + "resolve_comment", + "search_by_tag", + "search_by_title", + "search_text", + "vault_info" + ], + "annotations": { + "add_comment": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "add_comment", "args": { "path": "projects/plan.md", "body": "Looks good", "anchor_text": "ship it" }, "expect": { "match": { "body": "Looks good", "anchorText": "ship it" } } }, + { "tool": "list_comments", "args": { "path": "Projects/Plan.md" }, "expect": { "contains": ["Looks good"] } }, + { "tool": "add_comment", "args": { "path": "Journal/Diary.md", "body": "x" }, "expect": { "error": "Note not found: Journal/Diary.md" } }, + { "tool": "toggle_task", "args": { "id": "Projects/Plan.md#0" }, "expect": { "notOffered": true } } + ], + "disk": { + "equals": { "Projects/Plan.md": "# Plan\n\n- [ ] ship it\n\nSee [[Diary]]. #proj needle\n" } + } + }, + { + "id": "full-access-lists-every-tool-with-annotations", + "vault": "root", + "flags": { "access": "write" }, + "tools": { + "count": 34, + "include": ["empty_trash", "delete_folder"], + "annotations": { + "list_notes": { "readOnlyHint": true, "idempotentHint": false, "openWorldHint": false }, + "write_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "replace_in_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "delete_note": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "delete_folder": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "empty_trash": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false }, + "append_to_note": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }, + "move_to_trash": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }, + "create_note": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false } + } + }, + "calls": [ + { "tool": "vault_info", "args": {}, "expect": { "contains": ["$VAULT"], "absentKeys": ["access"] } } + ] + }, + { + "id": "deny-only-keeps-the-rest-of-the-vault", + "vault": "root", + "flags": { "deny": ["Journal"] }, + "tools": { "count": 33, "absent": ["empty_trash"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "paths": [ + "Areas/Health/Clinic/Bills.md", + "Areas/Health/Clinic/Notes.md", + "Areas/Health/Clinic/Visit.md", + "Areas/Health/Run.md", + "Areas/Money.md", + "Projects/Clients/Acme Two.md", + "Projects/Clients/Acme.md", + "Projects/Plan.md", + "Projects/Sub/More/Again.md", + "Projects/Sub/More/Further.md", + "Projects/Sub/More/Last.md", + "Projects/Sub/Step.md", + "archive/Projects/Old.md" + ] + } + }, + { "tool": "list_assets", "args": {}, "expect": { "contains": ["attachements/pic.png"] } }, + { "tool": "backlinks", "args": { "path": "Projects/Plan.md" }, "expect": { "paths": ["Areas/Health/Run.md"] } }, + { "tool": "create_note", "args": { "folder": "inbox", "title": "Top" }, "expect": { "match": { "path": "Top.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "journal", "title": "Nope" }, "expect": { "error": "This assistant cannot put notes there." } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Journal/Sub", "title": "Nope" }, "expect": { "error": "This assistant cannot put notes there." } }, + { "tool": "restore_from_trash", "args": { "path": "trash/Projects/Thrown.md" }, "expect": { "match": { "path": "Projects/Thrown.md", "folder": "inbox" } } }, + { "tool": "delete_folder", "args": { "folder": "inbox", "subpath": "Areas/Health/Clinic", "confirm": true }, "expect": { "ok": true } }, + { "tool": "empty_trash", "args": { "confirm": true }, "expect": { "notOffered": true } } + ], + "disk": { + "exists": ["Top.md", "Projects/Thrown.md", "Journal/Diary.md", "Journal/Sub/Deep.md"], + "absent": ["Areas/Health/Clinic", "Journal/Nope.md", "Journal/Sub/Nope.md"] + } + }, + { + "id": "an-allowed-note-shows-its-folders-by-name-only", + "vault": "root", + "flags": { "readOnly": true, "allow": ["areas/money.md"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Areas/Money.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["inbox:Areas"] } }, + { "tool": "read_note", "args": { "path": "Areas/Health/Run.md" }, "expect": { "error": "Note not found: Areas/Health/Run.md" } }, + { "tool": "search_text", "args": { "query": "needle" }, "expect": { "paths": ["Areas/Money.md"] } }, + { + "tool": "vault_info", + "args": {}, + "expect": { "match": { "access": { "level": "read", "partial": true, "allow": ["Areas/Money.md"], "missingAllow": [] } } } + } + ] + }, + { + "id": "vault-info-names-no-absolute-paths-or-deny-entries", + "vault": "root", + "flags": { "access": "read", "allow": ["projects", "Areas/Gone"], "deny": ["Projects/Clients"] }, + "calls": [ + { + "tool": "vault_info", + "args": {}, + "expect": { + "match": { + "kind": "local", + "primaryNotesLocation": "root", + "topFolders": ["inbox", "quick", "archive", "trash"], + "access": { "level": "read", "partial": true, "allow": ["Areas/Gone", "Projects"], "missingAllow": ["Areas/Gone"] } + }, + "absentKeys": ["vaultRoot", "inboxAbsolutePath", "vaultPath"], + "excludes": ["$VAULT", "Clients", "Journal", "blocked"] + } + }, + { "tool": "read_note", "args": { "path": "Journal/Diary.md" }, "expect": { "error": "Note not found: Journal/Diary.md", "excludes": ["$VAULT"] } } + ] + }, + { + "id": "a-stale-deny-entry-shares-nothing", + "vault": "root", + "flags": { "access": "read", "deny": ["Diary Folder"] }, + "calls": [ + { + "tool": "list_notes", + "args": {}, + "expect": { + "error": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + { + "tool": "read_note", + "args": { "path": "Projects/Plan.md" }, + "expect": { + "error": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + { + "tool": "vault_info", + "args": {}, + "expect": { + "match": { + "subfolders": [], + "access": { + "level": "read", + "partial": true, + "allow": [], + "missingAllow": [], + "blocked": "This assistant's scope hides a folder or note that does not exist in this vault (renamed or deleted outside ZenNotes, or it belongs to another vault), so nothing is shared until the MCP client's --deny-path entries are fixed." + } + }, + "excludes": ["Diary Folder"] + } + } + ] + }, + { + "id": "deny-matches-across-case-and-accents", + "vault": "accents", + "flags": { "access": "read", "deny": ["cafe"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Cafeteria/Lunch.md", "Notes/Ideas.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["inbox:Cafeteria", "inbox:Notes"] } }, + { "tool": "read_note", "args": { "path": "Café/Menu.md" }, "expect": { "error": "Note not found: Café/Menu.md" } }, + { "tool": "read_note", "args": { "path": "CAFÉ/menu.md" }, "expect": { "error": "Note not found: CAFÉ/menu.md" } }, + { "tool": "search_text", "args": { "query": "croissant" }, "expect": { "paths": [] } } + ] + }, + { + "id": "deny-matches-a-decomposed-spelling", + "vault": "accents", + "flags": { "access": "read", "deny": ["CAFÉ"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Cafeteria/Lunch.md", "Notes/Ideas.md"] } } + ] + }, + { + "id": "allow-takes-the-vault-spelling", + "vault": "accents", + "flags": { "access": "read", "allow": ["cafe"] }, + "calls": [ + { "tool": "list_notes", "args": {}, "expect": { "paths": ["Café/Menu.md"] } }, + { "tool": "read_note", "args": { "path": "cafe/menu.md" }, "expect": { "match": { "path": "Café/Menu.md" } } }, + { "tool": "read_note", "args": { "path": "Cafeteria/Lunch.md" }, "expect": { "error": "Note not found: Cafeteria/Lunch.md" } }, + { "tool": "vault_info", "args": {}, "expect": { "match": { "access": { "allow": ["Café"], "missingAllow": [] } } } } + ] + }, + { + "id": "remapped-system-folders-need-no-translation", + "vault": "remapped", + "flags": { "access": "write", "allow": ["01 Inbox/Projects", "99 Archive/Projects"] }, + "calls": [ + { "tool": "list_notes", "args": { "folder": "archive" }, "expect": { "paths": ["99 Archive/Projects/Old.md"] } }, + { "tool": "list_notes", "args": {}, "expect": { "paths": ["01 Inbox/Projects/A.md", "99 Archive/Projects/Old.md"] } }, + { "tool": "list_folders", "args": {}, "expect": { "folders": ["archive:Projects", "inbox:Projects"] } }, + { "tool": "read_note", "args": { "path": "01 Inbox/Private/B.md" }, "expect": { "error": "Note not found: 01 Inbox/Private/B.md" } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Projects", "title": "C" }, "expect": { "match": { "path": "01 Inbox/Projects/C.md" } } }, + { "tool": "create_note", "args": { "folder": "inbox", "subpath": "Private", "title": "D" }, "expect": { "error": "This assistant can create and move notes only inside: 01 Inbox/Projects, 99 Archive/Projects." } }, + { "tool": "archive_note", "args": { "path": "01 Inbox/Projects/A.md" }, "expect": { "match": { "path": "99 Archive/Projects/A.md", "folder": "archive" } } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "match": { "path": "01 Inbox/Projects/A.md", "folder": "inbox" } } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Private/Gone.md" }, "expect": { "error": "Note not found: 99 Archive/Private/Gone.md" } } + ], + "disk": { + "exists": ["01 Inbox/Projects/A.md", "01 Inbox/Projects/C.md", "99 Archive/Private/Gone.md"], + "absent": ["01 Inbox/Private/D.md", "99 Archive/Projects/A.md"] + } + }, + { + "id": "archiving-is-an-exit-from-the-scope", + "vault": "remapped", + "flags": { "access": "write", "allow": ["01 Inbox/Projects"] }, + "calls": [ + { "tool": "archive_note", "args": { "path": "01 Inbox/Projects/A.md" }, "expect": { "ok": true } }, + { "tool": "read_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "error": "Note not found: 99 Archive/Projects/A.md" } }, + { "tool": "unarchive_note", "args": { "path": "99 Archive/Projects/A.md" }, "expect": { "error": "Note not found: 99 Archive/Projects/A.md" } } + ], + "disk": { + "exists": ["99 Archive/Projects/A.md"], + "absent": ["01 Inbox/Projects/A.md"] + } + } + ] +} diff --git a/internal/mcp/testdata/mcp-scope.json.source.json b/internal/mcp/testdata/mcp-scope.json.source.json new file mode 100644 index 0000000..d0ff93c --- /dev/null +++ b/internal/mcp/testdata/mcp-scope.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/mcp-scope.json", + "sha256": "076d54482ccf168cfcd0886cdef5eff3cb9282cbbc6be938bf6501498deb43aa" +} diff --git a/internal/tui/database_test.go b/internal/tui/database_test.go index 012d801..00dbaf3 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -1,8 +1,10 @@ package tui import ( + "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -323,3 +325,107 @@ func TestOpenRecordPageLinksAPageFileAlreadyThere(t *testing.T) { t.Fatalf("the page changed: %q", kept) } } + +// As + Row does in the app's grid, a row added while a view with picked +// rows is on screen joins that view's picks, or the app would leave it out +// of the view it was added in. Other views keep their picks, and a view of +// every row changes nothing. +func TestAddRowJoinsThePicksOfTheViewOnScreen(t *testing.T) { + a, root := newDatabaseTestApp(t) + v := openTestDatabase(t, a) + ids := map[string]string{} + for _, row := range v.doc.Rows { + ids[v.doc.RecordTitle(row)] = row.ID + } + sidecarPath := filepath.Join(root, "Books.csv.base.json") + raw, err := os.ReadFile(sidecarPath) + if err != nil { + t.Fatal(err) + } + sidecar, err := database.ParseObject(string(raw)) + if err != nil { + t.Fatal(err) + } + views := []any{} + for _, spec := range []string{ + `{"id":"shelf","name":"Shelf","type":"table","filters":[],"sorts":[],"pickedRowIds":["` + ids["Dune"] + `"]}`, + `{"id":"all","name":"All","type":"table","filters":[],"sorts":[]}`, + `{"id":"later","name":"Later","type":"table","filters":[],"sorts":[],"pickedRowIds":["` + ids["Neuromancer"] + `"]}`, + } { + view, err := database.ParseObject(spec) + if err != nil { + t.Fatal(err) + } + views = append(views, view) + } + sidecar.Set("views", views) + sidecar.Set("activeViewId", "shelf") + text, err := database.Stringify(sidecar) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sidecarPath, []byte(text+"\n"), 0o644); err != nil { + t.Fatal(err) + } + v.refresh(a) + v.render(a, 100, 20, true) + + add := func(title string) string { + t.Helper() + v.handleKey(a, vim.R('a')) + typeText(a, title) + a.overlay.handleKey(a, enterKey()) + for _, row := range v.doc.Rows { + if v.doc.RecordTitle(row) == title { + return row.ID + } + } + t.Fatalf("a did not add %q", title) + return "" + } + onDisk := func() map[string][]string { + t.Helper() + raw, err := os.ReadFile(sidecarPath) + if err != nil { + t.Fatal(err) + } + var schema struct { + Views []struct { + ID string `json:"id"` + PickedRowIDs *[]string `json:"pickedRowIds"` + } `json:"views"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + out := map[string][]string{} + for _, view := range schema.Views { + if view.PickedRowIDs != nil { + out[view.ID] = *view.PickedRowIDs + } + } + return out + } + + if v.activeView().ID != "shelf" { + t.Fatalf("opened on %q", v.activeView().ID) + } + ulysses := add("Ulysses") + want := map[string][]string{"shelf": {ids["Dune"], ulysses}, "later": {ids["Neuromancer"]}} + if got := onDisk(); !reflect.DeepEqual(got, want) { + t.Fatalf("after an add on Shelf: %v", got) + } + + v.switchView(a, "later") + emma := add("Emma") + want["later"] = []string{ids["Neuromancer"], emma} + if got := onDisk(); !reflect.DeepEqual(got, want) { + t.Fatalf("after an add on Later: %v", got) + } + + v.switchView(a, "all") + add("Persuasion") + if got := onDisk(); !reflect.DeepEqual(got, want) { + t.Fatalf("an add on a view of every row changed picks: %v", got) + } +} diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index 2a48e6b..dcb1498 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -218,7 +218,7 @@ func helpLines(a *App, filter string) []string { "i / Enter\tEdit the cell, type-aware: pickers for select, multi-select and note links (Tab toggles, Enter commits), toggles a checkbox, date shorthand for dates", "[[ in a text cell\tLink a note from the cell", "x / Ctrl+A / Esc\tSelect the row, select every row, clear the selection", - "a / dd / D\tAdd a row; delete the selected rows (or the current one); duplicate the row", + "a / dd / D\tAdd a row (in a view that shows picked rows, it joins them); delete the selected rows (or the current one); duplicate the row", "m\tRow menu: open page, duplicate, delete, copy as CSV", "o\tOpen (or create) the record's page; Ctrl+O comes back", "k (into the header) / Esc\tEnter and leave the header row", diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index 2d662a3..c2b811e 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -932,8 +932,15 @@ func (v *databaseView) addRow(a *App) { groupID, groupValue = v.board.group.ID, key } } + viewID := "" + if view := v.activeView(); view != nil { + viewID = view.ID + } a.promptFor("New row", "", "Title", func(a *App, text string) { - row := v.doc.AddRow() + // As + Row does in the app's grid: in a view that shows picked rows + // the new row joins its picks, or the app would leave it out of the + // view it was added in. Other views keep their picks. + row, _ := v.doc.AddRowInView(viewID) if titleID != "" { v.doc.SetCell(row.ID, titleID, strings.TrimSpace(text)) } diff --git a/internal/vault/settings.go b/internal/vault/settings.go index 0b50335..381b960 100644 --- a/internal/vault/settings.go +++ b/internal/vault/settings.go @@ -367,6 +367,22 @@ func SystemFolderForDirName(name string, paths map[string]string) (NoteFolder, b return "", false } +// IsReservedRootName reports whether a top-level name is kept out of the +// root-mode note tree: an asset or internal directory, or the resolved +// directory of any system folder. Case-insensitive, unlike the walkers' +// exact skip list, because it guards names a client typed: on a +// case-insensitive volume `Attachements/` is the attachments directory. +func IsReservedRootName(name string, paths map[string]string) bool { + lower := strings.ToLower(name) + for reserved := range reservedNonSystemRootNames { + if strings.ToLower(reserved) == lower { + return true + } + } + _, system := SystemFolderForDirName(name, paths) + return system +} + // --- tasks folder lists (mirrors shared-domain/tasks-excluded-folders.ts) --- // The excluded folders (#458) and the Task folders (#962) share one set of // validation rules; keep them byte-compatible with the desktop and the server. diff --git a/internal/vault/vault.go b/internal/vault/vault.go index cdbaa4b..ac55b2f 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -578,6 +578,14 @@ func (v *Vault) CreateNote(folder NoteFolder, title, subpath string, body *strin // follows when SyncTitleHeading is on, and every `[[wikilink]]` in the vault // that resolved to the note is rewritten to the new name. func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { + return v.RenameNoteRewritingLinksIn(rel, nextTitle, nil) +} + +// RenameNoteRewritingLinksIn is RenameNote with the inbound link rewrite +// limited to the notes rewrite accepts; nil accepts every note. A scoped MCP +// client renames this way so its rename never writes into notes it cannot +// see (ZenNotes/tui#21). +func (v *Vault) RenameNoteRewritingLinksIn(rel, nextTitle string, rewrite func(path string) bool) (NoteMeta, error) { oldRel := NormalizeRelPath(rel) notesBefore, _ := v.ListNotes() meta, err := v.renameNoteFile(rel, nextTitle) @@ -585,7 +593,7 @@ func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { return NoteMeta{}, err } if meta.Path != oldRel { - v.rewriteInboundWikilinks(notesBefore, oldRel, meta.Title) + v.rewriteInboundWikilinks(notesBefore, oldRel, meta.Title, rewrite) } return meta, nil } @@ -649,11 +657,14 @@ func (v *Vault) syncTitleHeading(sourceAbs, targetAbs, title string) { } } -func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitle string) { +func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitle string, rewrite func(path string) bool) { for _, n := range notesBefore { if n.Path == oldPath || n.Folder == FolderTrash { continue } + if rewrite != nil && !rewrite(n.Path) { + continue + } linksToIt := false for _, t := range n.Wikilinks { if r, ok := ResolveWikilink(notesBefore, t); ok && r.Path == oldPath {