Skip to content

Commit d48a30a

Browse files
committed
feat(vscode): add vault write protection mode setting
Introduce `obsidianVFS.vault.mode` (`"rw" | "ro" | "partial"`) to control write access through the obs:// FileSystemProvider. Read-only mode registers the provider with `isReadonly: true`; partial mode restricts writes to autoMount paths via mount tree validation. Guards all five write methods (writeFile, createDirectory, delete, rename, copy) with a `checkVaultMode` check before path validation. Provider re-registers dynamically on mode change to update the isReadonly flag. Status bar shows lock icon with mode suffix for non-rw modes. Protection covers obs:// tree view operations, drag-and-drop into the tree, and obs:// editor saves. The file:// workspace folder (Explorer, Quick Open, Search) bypasses the provider — documented as a known limitation pending FileSearchProvider/TextSearchProvider stabilization. Assisted-by: Claude
1 parent 98775c8 commit d48a30a

17 files changed

Lines changed: 472 additions & 19 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Style, file layout, patterns: [CONTRIBUTING.md#conventions](CONTRIBUTING.md#conv
3535
- **Vault-side exclusion toggles**`vault.excludeDotfiles` + `vault.excludeDotfilePattern` control dotfile hiding; `vault.excludeBlocked` controls blocked-path hiding; `vault.gitIgnore` controls `git.ignoredRepositories` management. Each is independently toggleable at runtime.
3636
- **Sub-path exclusion** — when `autoMount` contains nested paths (e.g., `["20-areas/idea"]`), `files.exclude` patterns hide sibling directories (`20-areas/career`, `20-areas/otaviof`) to match tree view visibility. Computed via mount tree (`packages/core/src/mount-tree.ts`). Controlled by `workspace.excludeUnmountedFolders`.
3737
- **File-level exclusion**`obsidianVFS.workspace.excludeUnmountedFilePattern` provides a regex tested against file basenames at partially-mounted levels. Matching files generate `{prefix}/*{ext}` glob patterns in the folder-scoped `files.exclude` tier, hiding Obsidian files (`.md`, `.base`, `.canvas`) from Explorer and Quick Open without affecting same-named files in other workspace folders. Gated by `workspace.excludeUnmountedFiles` toggle. Default: `\\.(md|base|canvas)$`.
38+
- **Vault write protection**`obsidianVFS.vault.mode` controls write access through the `obs://` FileSystemProvider. `"ro"` registers the provider with `isReadonly: true`; `"partial"` enforces autoMount-scoped writes at the provider level. Protection covers tree view operations, drag-and-drop into the tree, and `obs://` editor saves. The `file://` workspace folder (Explorer panel, Quick Open, Search) bypasses the provider entirely — drag-and-drop, rename, delete, and editor saves via `file://` remain writable regardless of vault mode. This gap closes when `FileSearchProvider`/`TextSearchProvider` stabilize.
3839
- **`obs://` FileSystemProvider** remains registered for the Explorer tree view sidebar, wikilinks, drag-and-drop, and watch events — it does not back a workspace folder.
3940
- **`FileSearchProvider`/`TextSearchProvider` are proposed (unstable) APIs** as of `@types/vscode@1.118.0`. When stabilized, the extension can switch to a single `obs://` workspace folder with native search, eliminating the `files.exclude` workaround. Check `@types/vscode` for stable availability — do not use while proposed.
4041

packages/core/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ export type {
1313
MentionResult,
1414
ResolutionResult,
1515
VaultContext,
16+
VaultMode,
1617
VFSConfig,
1718
VFSError,
1819
VFSFileStat,
1920
VFSFileType,
2021
VFSResult,
2122
WikilinkResolution,
2223
} from "./types.js";
23-
export { ERR, ERRNO } from "./types.js";
24+
export { ERR, ERRNO, VAULT_MODE } from "./types.js";
2425

2526
/**
2627
* Parsed `obs://` URI components.
@@ -88,6 +89,7 @@ export {
8889
canonicalizePath,
8990
isAllowedPath,
9091
checkBlockedFolder,
92+
checkVaultMode,
9193
} from "./path-security.js";
9294

9395
/**

packages/core/src/path-security.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {
55
checkAllowedFolder,
66
checkBlockedFolder,
77
checkSymlink,
8+
checkVaultMode,
89
isAllowedPath,
910
validatePath,
1011
validatePathForWrite,
1112
} from "./path-security.js";
13+
import type { VaultMode } from "./types.js";
1214
import { mockFsFunction } from "./test-helpers.js";
1315

1416
vi.mock("node:fs/promises", () => ({
@@ -409,3 +411,112 @@ describe("validatePathForWrite", () => {
409411
expect(result).toEqual({ ok: true, value: "/vault/notes/new.md" });
410412
});
411413
});
414+
415+
describe("checkVaultMode", () => {
416+
it.each<{
417+
name: string;
418+
path: string;
419+
mode: VaultMode;
420+
autoMount: string[];
421+
ok: boolean;
422+
message?: string;
423+
}>([
424+
{ name: "rw permits any path", path: "any/path.md", mode: "rw", autoMount: [], ok: true },
425+
{
426+
name: "rw permits path even with autoMount set",
427+
path: "unmounted/file.md",
428+
mode: "rw",
429+
autoMount: ["Notes"],
430+
ok: true,
431+
},
432+
{
433+
name: "ro denies any path",
434+
path: "Notes/file.md",
435+
mode: "ro",
436+
autoMount: [],
437+
ok: false,
438+
message: "Vault is read-only",
439+
},
440+
{
441+
name: "ro denies with autoMount set",
442+
path: "Notes/file.md",
443+
mode: "ro",
444+
autoMount: ["Notes"],
445+
ok: false,
446+
message: "Vault is read-only",
447+
},
448+
{
449+
name: "partial with exact match permits write",
450+
path: "Notes/file.md",
451+
mode: "partial",
452+
autoMount: ["Notes"],
453+
ok: true,
454+
},
455+
{
456+
name: "partial with nested mount permits write",
457+
path: "20-areas/idea/plan.md",
458+
mode: "partial",
459+
autoMount: ["20-areas/idea"],
460+
ok: true,
461+
},
462+
{
463+
name: "partial with parent of mount denies write",
464+
path: "20-areas/work/plan.md",
465+
mode: "partial",
466+
autoMount: ["20-areas/idea"],
467+
ok: false,
468+
message: "Path not within mounted folders",
469+
},
470+
{
471+
name: "partial with unmounted path denies write",
472+
path: "Archive/old.md",
473+
mode: "partial",
474+
autoMount: ["Notes"],
475+
ok: false,
476+
message: "Path not within mounted folders",
477+
},
478+
{
479+
name: "partial with empty autoMount denies all paths",
480+
path: "any/path.md",
481+
mode: "partial",
482+
autoMount: [],
483+
ok: false,
484+
message: "Path not within mounted folders",
485+
},
486+
{
487+
name: "partial with root-level file not in mount denies write",
488+
path: "readme.md",
489+
mode: "partial",
490+
autoMount: ["Notes"],
491+
ok: false,
492+
message: "Path not within mounted folders",
493+
},
494+
{
495+
name: "partial permits writing to mounted dir itself",
496+
path: "Notes",
497+
mode: "partial",
498+
autoMount: ["Notes"],
499+
ok: true,
500+
},
501+
{
502+
name: "partial permits deeply nested path in mount",
503+
path: "Notes/deep/nested/file.md",
504+
mode: "partial",
505+
autoMount: ["Notes"],
506+
ok: true,
507+
},
508+
{
509+
name: "partial with multiple mounts permits any mounted path",
510+
path: "Projects/active/todo.md",
511+
mode: "partial",
512+
autoMount: ["Notes", "Projects"],
513+
ok: true,
514+
},
515+
])("$name", ({ path, mode, autoMount, ok, message }) => {
516+
const result = checkVaultMode(path, mode, autoMount);
517+
expect(result.ok).toBe(ok);
518+
if (!ok && message) {
519+
expect((result as { ok: false; error: { message: string } }).error.message).toBe(message);
520+
}
521+
});
522+
});

packages/core/src/path-security.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { realpath } from "node:fs/promises";
22
import path from "node:path";
33

4-
import { ERR, ERRNO } from "./types.js";
5-
import type { VFSResult } from "./types.js";
4+
import { ERR, ERRNO, VAULT_MODE } from "./types.js";
5+
import type { VFSResult, VaultMode } from "./types.js";
6+
import { buildMountTree } from "./mount-tree.js";
67

78
/**
89
* Immutable parameters for path security checks — vault root, folder allowlist,
@@ -120,6 +121,43 @@ export async function checkSymlink(
120121
}
121122
}
122123

124+
/**
125+
* Check whether a write to `virtualPath` is permitted under the given
126+
* vault mode. Returns `PERMISSION_DENIED` when the write is blocked.
127+
*
128+
* - `"rw"`: always permits.
129+
* - `"ro"`: always rejects.
130+
* - `"partial"`: permits if `virtualPath` falls within an `autoMount` entry.
131+
*/
132+
export function checkVaultMode(
133+
virtualPath: string,
134+
mode: VaultMode,
135+
autoMount: readonly string[],
136+
): VFSResult<string> {
137+
if (mode === VAULT_MODE.RW) return { ok: true, value: virtualPath };
138+
if (mode === VAULT_MODE.RO) {
139+
return {
140+
ok: false,
141+
error: { code: ERR.PERMISSION_DENIED, message: "Vault is read-only" },
142+
};
143+
}
144+
const tree = buildMountTree(autoMount);
145+
const segments = virtualPath.split("/").filter(Boolean);
146+
let node: ReturnType<typeof buildMountTree> | null = tree;
147+
for (const seg of segments) {
148+
if (node === null) return { ok: true, value: virtualPath };
149+
const child = node.get(seg);
150+
if (child === undefined) {
151+
return {
152+
ok: false,
153+
error: { code: ERR.PERMISSION_DENIED, message: "Path not within mounted folders" },
154+
};
155+
}
156+
node = child;
157+
}
158+
return { ok: true, value: virtualPath };
159+
}
160+
123161
/**
124162
* Compose all path security checks in order: canonicalize → allowed/blocked →
125163
* symlink. Short-circuits on first failure. Returns the validated absolute path.

packages/core/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ export interface Disposable {
7979
dispose(): void;
8080
}
8181

82+
/** Vault access mode constants. */
83+
export const VAULT_MODE = {
84+
RW: "rw",
85+
RO: "ro",
86+
PARTIAL: "partial",
87+
} as const;
88+
89+
/** Vault access mode controlling write operations through the obs:// FileSystemProvider. */
90+
export type VaultMode = (typeof VAULT_MODE)[keyof typeof VAULT_MODE];
91+
8292
/**
8393
* Filesystem entry type for directory enumeration.
8494
*/

packages/vscode/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Controls vault-side `files.exclude` policy written to `<vault>/.vscode/settings.
5454

5555
| Setting | Type | Default | Description |
5656
|---------|------|---------|-------------|
57+
| `obsidianVFS.vault.mode` | `"rw" \| "ro" \| "partial"` | `"rw"` | Write access mode: `"rw"` (read-write, all writes allowed), `"ro"` (read-only, no writes through `obs://`), `"partial"` (writes only to `autoMount` paths) |
5758
| `obsidianVFS.vault.gitIgnore` | `boolean` | `true` | Add the vault to `git.ignoredRepositories` so VS Code's Git extension skips it |
5859
| `obsidianVFS.vault.excludeBlocked` | `boolean` | `true` | Hide `blocked` folders (from `.obsidian/obsidian-vfs.json`) via `files.exclude` |
5960
| `obsidianVFS.vault.excludeDotfiles` | `boolean` | `true` | Hide dotfiles and dotdirs at the vault root (`.obsidian`, `.trash`, etc.) |
@@ -122,6 +123,7 @@ This matches the tree view — only mounted paths appear in both views.
122123

123124
- **Vault `.vscode/` directory:** The extension creates `.vscode/settings.json` inside the vault for vault-global `files.exclude` patterns (dotfiles and `blocked` paths). These patterns are independent of `autoMount` and apply to any workspace that includes the vault. The `.vscode/` directory itself is never managed by the extension — if you want to hide it, add `.vscode` to your own `files.exclude`. When `obsidianVFS.workspace.enabled` is disabled, all managed patterns are removed from both folder and workspace settings. If your vault is git-tracked, consider adding `.vscode/` to the vault's `.gitignore`.
124125
- **Not a security boundary:** `files.exclude` hides content from Explorer and Quick Open but does not enforce access restrictions. The `obs://` `FileSystemProvider`'s path security (`allowed`/`blocked` lists in `.obsidian-vfs.json`) applies to tree view, wikilink, and drag-and-drop operations.
126+
- **Write protection covers `obs://` only:** `vault.mode` (`"ro"` / `"partial"`) guards write operations through the `obs://` `FileSystemProvider` — tree view sidebar, drag-and-drop into the tree, and `obs://` editor saves. The `file://` workspace folder (Explorer panel, Quick Open, Search) bypasses the provider and remains writable regardless of vault mode. This gap closes when VS Code stabilizes `FileSearchProvider`/`TextSearchProvider`, allowing a single `obs://` workspace folder.
125127
- **Temporary workaround:** `files.exclude` is used because VS Code's [`FileSearchProvider`/`TextSearchProvider`](https://github.com/microsoft/vscode/issues/73524) APIs are still proposed (unstable). When stabilized, the extension can switch to a single `obs://` workspace folder with native search, eliminating the `files.exclude` patterns entirely.
126128
- **Title bar:** Adding the vault as a workspace folder creates a multi-root workspace. VS Code may show "UNTITLED (WORKSPACE)" in the title bar.
127129

packages/vscode/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@
136136
"default": "^\\.",
137137
"markdownDescription": "Regex tested against entry names at the vault root. Only dotfiles matching this pattern are hidden when `vault.excludeDotfiles` is enabled. Default `^\\\\.` matches all dotfiles. Example: `^\\\\.(obsidian|trash)` to hide only `.obsidian` and `.trash`."
138138
},
139+
"obsidianVFS.vault.mode": {
140+
"type": "string",
141+
"enum": [
142+
"rw",
143+
"ro",
144+
"partial"
145+
],
146+
"default": "rw",
147+
"markdownDescription": "Vault access mode controlling write operations through the `obs://` file system.\n- `rw`: Read-write — all writes permitted (default).\n- `ro`: Read-only — no writes allowed.\n- `partial`: Only writes to `autoMount` paths are allowed; everything else is read-only.\n\nThis setting protects tree view operations, drag-and-drop, and `obs://` editor saves. The `file://` workspace folder (used for Quick Open and Search) is not affected."
148+
},
139149
"obsidianVFS.statusBar.enabled": {
140150
"type": "boolean",
141151
"default": true,

packages/vscode/src/bootstrap.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as vscode from "vscode";
2-
import { bootstrapTracker, resolveCliPath } from "@obsidian-vfs/core";
3-
import type { BootstrapResult, VFSResult } from "@obsidian-vfs/core";
2+
import { bootstrapTracker, resolveCliPath, VAULT_MODE } from "@obsidian-vfs/core";
3+
import type { BootstrapResult, VaultMode, VFSResult } from "@obsidian-vfs/core";
44

55
import { CONFIG_PROP, CONFIG_SECTION } from "./types.js";
66
import type { ExtensionConfig } from "./types.js";
@@ -18,6 +18,7 @@ export function readConfig(): ExtensionConfig {
1818
vaultExcludeBlocked: cfg.get<boolean>(CONFIG_PROP.vaultExcludeBlocked)!,
1919
vaultExcludeDotfiles: cfg.get<boolean>(CONFIG_PROP.vaultExcludeDotfiles)!,
2020
vaultExcludeDotfilePattern: cfg.get<string>(CONFIG_PROP.vaultExcludeDotfilePattern)!,
21+
vaultMode: cfg.get<VaultMode>(CONFIG_PROP.vaultMode, VAULT_MODE.RW),
2122
// Status Bar
2223
statusBarEnabled: cfg.get<boolean>(CONFIG_PROP.statusBarEnabled)!,
2324
// Explorer

0 commit comments

Comments
 (0)