@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/core/src/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
@@ -0,0 +1,374 @@
1
+ /**
2
+ * vault#675 — a scoped caller must not be able to PROBE for an out-of-scope
3
+ * tag by naming it in a query and reading hit/miss.
4
+ *
5
+ * #568/#674 stopped scoped reads from DISCLOSING an out-of-scope co-tag's
6
+ * name. The oracle left behind: the caller names it itself. A note tagged
7
+ * `["mine","project-manhattan"]` is admitted to a `mine`-scoped token via
8
+ * `mine`, so `?tag=project-manhattan` came back with a row — confirming the
9
+ * guessed name — while `?tag=some-tag-that-does-not-exist` came back empty.
10
+ * No name leaked; membership did. Same on all three doors (REST `?tag=`,
11
+ * MCP `query-notes { tag }`, live subscriptions).
12
+ *
13
+ * The fix (`scopeQueryTags` / `scopeQueryTagParam`, src/tag-scope.ts)
14
+ * rewrites an out-of-scope query tag to a name no note can carry, so an
15
+ * out-of-scope tag behaves EXACTLY as a tag that does not exist. Every test
16
+ * below is written as that comparison — the assertion is not "empty", it is
17
+ * "byte-identical to the nonexistent-tag control" — because equality with
18
+ * the control is the actual security property. `NONEXISTENT` is itself out
19
+ * of scope (every unknown name is), so the two requests differ in exactly
20
+ * one way: whether the named tag exists in the vault.
21
+ *
22
+ * The live-subscription door is covered in `ws-subscribe.test.ts` (it needs
23
+ * a real socket).
24
+ *
25
+ * Fixture + naming follow `tag-scope-note-tags.test.ts`: `mine` is in scope,
26
+ * `project-manhattan` is the out-of-scope tag being probed for. Every test
27
+ * carries an UNSCOPED control — the fix must be invisible to unscoped
28
+ * tokens, which still query the co-tag normally.
29
+ */
30
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
31
+ import { mkdirSync, rmSync, existsSync } from "fs";
32
+ import { join } from "path";
33
+ import { tmpdir } from "os";
34
+ import { writeVaultConfig } from "./config.ts";
35
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
36
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
37
+ import { handleNotes, type TagScopeCtx } from "./routes.ts";
38
+ import { expandTokenTagScope, OUT_OF_SCOPE_QUERY_TAG } from "./tag-scope.ts";
39
+ import { stripTagHash } from "../core/src/tag-hierarchy.ts";
40
+ import type { AuthResult } from "./auth.ts";
41
+ import type { Store } from "../core/src/types.ts";
42
+
43
+ const OUT_OF_SCOPE = "project-manhattan";
44
+ /** The control: a name that is equally out of scope, but does NOT exist. */
45
+ const NONEXISTENT = "tag-that-does-not-exist";
46
+
47
+ let tmpHome: string;
48
+ let prevHome: string | undefined;
49
+
50
+ beforeEach(() => {
51
+ tmpHome = join(tmpdir(), `vault-675-${Date.now()}-${Math.random().toString(36).slice(2)}`);
52
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
53
+ prevHome = process.env.PARACHUTE_HOME;
54
+ process.env.PARACHUTE_HOME = tmpHome;
55
+ clearVaultStoreCache();
56
+ });
57
+
58
+ afterEach(() => {
59
+ clearVaultStoreCache();
60
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
61
+ else process.env.PARACHUTE_HOME = prevHome;
62
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
63
+ });
64
+
65
+ function seedVault(name: string): Store {
66
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
67
+ return getVaultStore(name);
68
+ }
69
+
70
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
71
+ return {
72
+ permission: "full",
73
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
74
+ legacyDerived: false,
75
+ scoped_tags: scopedTags,
76
+ vault_name: null,
77
+ caller_jti: null,
78
+ actor: "test-user",
79
+ via: "api",
80
+ } as AuthResult;
81
+ }
82
+
83
+ async function restScope(store: Store, scopedTags: string[] | null): Promise<TagScopeCtx> {
84
+ return { allowed: await expandTokenTagScope(store, scopedTags), raw: scopedTags };
85
+ }
86
+
87
+ async function rest(store: Store, scope: TagScopeCtx, query: string): Promise<any> {
88
+ const req = new Request(`http://localhost/vault/v/api/notes${query}`, { method: "GET" });
89
+ const res = await handleNotes(req, store, "", "v", scope);
90
+ return { status: res.status, body: await res.json() };
91
+ }
92
+
93
+ /**
94
+ * The corpus every test probes:
95
+ * - `CoTagged` — `["mine", OUT_OF_SCOPE]`: visible via `mine`. THE oracle
96
+ * — it is the row a probe for `OUT_OF_SCOPE` used to return.
97
+ * - `MineOnly` — `["mine"]`: the in-scope baseline.
98
+ * - `HiddenOnly` — `[OUT_OF_SCOPE]`: already invisible pre-fix.
99
+ */
100
+ async function seedCorpus(store: Store): Promise<void> {
101
+ await store.createNote("co-tagged body", { path: "CoTagged", tags: ["mine", OUT_OF_SCOPE] });
102
+ await store.createNote("mine only", { path: "MineOnly", tags: ["mine"] });
103
+ await store.createNote("hidden", { path: "HiddenOnly", tags: [OUT_OF_SCOPE] });
104
+ }
105
+
106
+ const paths = (body: any): string[] =>
107
+ (Array.isArray(body) ? body : (body?.notes ?? [])).map((n: any) => n.path).sort();
108
+
109
+ describe("vault#675 — the substituted tag itself", () => {
110
+ /**
111
+ * The whole fix rests on two properties of `OUT_OF_SCOPE_QUERY_TAG`, and
112
+ * both fail SILENTLY and CATASTROPHICALLY if someone edits the constant to
113
+ * something more readable. Pinned here rather than trusted:
114
+ *
115
+ * 1. It survives tag normalization. `SqliteStore.normalizeQueryTags` maps
116
+ * `stripTagHash` over query tags and DROPS the empties; if the
117
+ * substitute normalized away, `tags` would collapse to `[]`, the tag
118
+ * filter would be skipped entirely, and an out-of-scope probe would
119
+ * return the caller's WHOLE visible set instead of nothing.
120
+ * 2. Even a note DELIBERATELY tagged with it can't widen what a scoped
121
+ * caller sees. The substitution changes which notes the query matches;
122
+ * it does not touch the result-side scope filter, so a planted decoy is
123
+ * dropped exactly like any other out-of-scope note.
124
+ */
125
+ test("survives stripTagHash — a tag filter naming it is never normalized away", () => {
126
+ expect(stripTagHash(OUT_OF_SCOPE_QUERY_TAG)).toBe(OUT_OF_SCOPE_QUERY_TAG);
127
+ expect(OUT_OF_SCOPE_QUERY_TAG).not.toBe("");
128
+ });
129
+
130
+ test("a note deliberately tagged with it is still invisible to a scoped probe", async () => {
131
+ const store = seedVault("v");
132
+ await seedCorpus(store);
133
+ // Storage does not forbid the name (nothing in a real tagging workflow —
134
+ // YAML front-matter, a JSON `tags` array — can produce a NUL, but the
135
+ // store doesn't police it). This is the residual case, and it is inert:
136
+ // the note carries no in-scope tag, so the unchanged result-side filter
137
+ // drops it.
138
+ await store.createNote("decoy", { path: "Decoy", tags: [OUT_OF_SCOPE_QUERY_TAG] });
139
+ const scope = await restScope(store, ["mine"]);
140
+
141
+ const probe = await rest(store, scope, `?tag=${OUT_OF_SCOPE}`);
142
+ const control = await rest(store, scope, `?tag=${NONEXISTENT}`);
143
+ expect(probe.body).toEqual(control.body);
144
+ expect(probe.body).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("vault#675 — REST door: an out-of-scope query tag matches nothing", () => {
149
+ test("?tag=<out-of-scope> — byte-identical to the same query naming a NONEXISTENT tag", async () => {
150
+ const store = seedVault("v");
151
+ await seedCorpus(store);
152
+ const scope = await restScope(store, ["mine"]);
153
+
154
+ const probe = await rest(store, scope, `?tag=${OUT_OF_SCOPE}`);
155
+ const control = await rest(store, scope, `?tag=${NONEXISTENT}`);
156
+
157
+ expect(probe.status).toBe(control.status);
158
+ expect(probe.body).toEqual(control.body);
159
+ expect(probe.body).toEqual([]);
160
+ expect(JSON.stringify(probe.body)).not.toContain(OUT_OF_SCOPE);
161
+ });
162
+
163
+ test("?tag=<out-of-scope> UNSCOPED control — the co-tagged note still comes back in full", async () => {
164
+ const store = seedVault("v");
165
+ await seedCorpus(store);
166
+ const scope = await restScope(store, null);
167
+
168
+ const probe = await rest(store, scope, `?tag=${OUT_OF_SCOPE}`);
169
+ expect(paths(probe.body)).toEqual(["CoTagged", "HiddenOnly"]);
170
+ });
171
+
172
+ test("tag_match=all — an out-of-scope tag ANDed with an in-scope one still matches nothing", async () => {
173
+ const store = seedVault("v");
174
+ await seedCorpus(store);
175
+ const scope = await restScope(store, ["mine"]);
176
+
177
+ const probe = await rest(store, scope, `?tag=mine&tag=${OUT_OF_SCOPE}&tag_match=all`);
178
+ const control = await rest(store, scope, `?tag=mine&tag=${NONEXISTENT}&tag_match=all`);
179
+
180
+ expect(probe.body).toEqual(control.body);
181
+ expect(probe.body).toEqual([]);
182
+ });
183
+
184
+ test("tag_match=any — the out-of-scope member contributes nothing, the in-scope member is unaffected", async () => {
185
+ const store = seedVault("v");
186
+ await seedCorpus(store);
187
+ // A SECOND in-scope root is what makes `any` an oracle: `OpsCoTagged`
188
+ // is visible via `ops`, so pre-fix the out-of-scope union member pulled
189
+ // it into an answer to a question about `mine`.
190
+ await store.createNote("ops co-tagged", { path: "OpsCoTagged", tags: ["ops", OUT_OF_SCOPE] });
191
+ const scope = await restScope(store, ["mine", "ops"]);
192
+
193
+ const probe = await rest(store, scope, `?tag=mine&tag=${OUT_OF_SCOPE}&tag_match=any`);
194
+ const control = await rest(store, scope, `?tag=mine&tag=${NONEXISTENT}&tag_match=any`);
195
+
196
+ expect(probe.body).toEqual(control.body);
197
+ expect(paths(probe.body)).toEqual(["CoTagged", "MineOnly"]);
198
+ });
199
+
200
+ test("exclude_tag=<out-of-scope> — excludes nothing, exactly as excluding a nonexistent tag does", async () => {
201
+ const store = seedVault("v");
202
+ await seedCorpus(store);
203
+ const scope = await restScope(store, ["mine"]);
204
+
205
+ const probe = await rest(store, scope, `?exclude_tag=${OUT_OF_SCOPE}`);
206
+ const control = await rest(store, scope, `?exclude_tag=${NONEXISTENT}`);
207
+
208
+ // Pre-fix this SHRANK the visible set (CoTagged dropped out) — a
209
+ // difference-of-counts oracle on the same tag name.
210
+ expect(probe.body).toEqual(control.body);
211
+ expect(paths(probe.body)).toEqual(["CoTagged", "MineOnly"]);
212
+ });
213
+
214
+ test("?search= composes the same way — the FTS branch lowers the same query tags", async () => {
215
+ const store = seedVault("v");
216
+ await seedCorpus(store);
217
+ const scope = await restScope(store, ["mine"]);
218
+
219
+ const probe = await rest(store, scope, `?search=body&tag=${OUT_OF_SCOPE}`);
220
+ const control = await rest(store, scope, `?search=body&tag=${NONEXISTENT}`);
221
+
222
+ expect(probe.body).toEqual(control.body);
223
+ expect(paths(probe.body)).toEqual([]);
224
+ });
225
+
226
+ test("?format=graph — the graph projection is the same query, so nodes[] is empty too", async () => {
227
+ const store = seedVault("v");
228
+ await seedCorpus(store);
229
+ const scope = await restScope(store, ["mine"]);
230
+
231
+ const probe = await rest(store, scope, `?format=graph&tag=${OUT_OF_SCOPE}`);
232
+ const control = await rest(store, scope, `?format=graph&tag=${NONEXISTENT}`);
233
+
234
+ expect(probe.body).toEqual(control.body);
235
+ expect(probe.body.nodes).toEqual([]);
236
+ });
237
+
238
+ test("?aggregate[op]=count — the rollup can't be used as a counting oracle either", async () => {
239
+ const store = seedVault("v");
240
+ await seedCorpus(store);
241
+ const scope = await restScope(store, ["mine"]);
242
+
243
+ const probe = await rest(store, scope, `?aggregate[op]=count&tag=${OUT_OF_SCOPE}`);
244
+ const control = await rest(store, scope, `?aggregate[op]=count&tag=${NONEXISTENT}`);
245
+
246
+ expect(probe.body).toEqual(control.body);
247
+ expect(probe.body).toEqual([{ group: null, value: 0 }]);
248
+ });
249
+
250
+ test("cursor envelope — same shape as the nonexistent-tag control (opaque cursor aside)", async () => {
251
+ const store = seedVault("v");
252
+ await seedCorpus(store);
253
+ const scope = await restScope(store, ["mine"]);
254
+
255
+ const probe = await rest(store, scope, `?tag=${OUT_OF_SCOPE}&cursor=`);
256
+ const control = await rest(store, scope, `?tag=${NONEXISTENT}&cursor=`);
257
+
258
+ expect(Object.keys(probe.body).sort()).toEqual(Object.keys(control.body).sort());
259
+ expect(probe.body.notes).toEqual([]);
260
+ // The cursor is opaque and derived from the caller's OWN query string —
261
+ // it carries no vault state. What matters is that it is the watermark of
262
+ // a genuinely empty page, which it is because the query really did match
263
+ // nothing rather than being emptied afterwards.
264
+ expect(typeof probe.body.next_cursor).toBe("string");
265
+ });
266
+
267
+ test("an IN-SCOPE query tag is untouched — including the `#`-prefixed form the engine strips", async () => {
268
+ const store = seedVault("v");
269
+ await seedCorpus(store);
270
+ await store.createNote("sub", { path: "Sub", tags: ["mine/sub"] });
271
+ const scope = await restScope(store, ["mine"]);
272
+
273
+ expect(paths((await rest(store, scope, "?tag=mine")).body)).toEqual(["CoTagged", "MineOnly"]);
274
+ expect(paths((await rest(store, scope, "?tag=%23mine")).body)).toEqual(["CoTagged", "MineOnly"]);
275
+ expect(paths((await rest(store, scope, "?tag=mine/sub")).body)).toEqual(["Sub"]);
276
+ });
277
+ });
278
+
279
+ describe("vault#675 — MCP door: identical semantics (one contract, two doors)", () => {
280
+ function toolset(vaultName: string, scopedTags: string[] | null) {
281
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags) as any);
282
+ return (name: string) => tools.find((t) => t.name === name)!;
283
+ }
284
+
285
+ test("query-notes { tag } — byte-identical to naming a NONEXISTENT tag", async () => {
286
+ const store = seedVault("v");
287
+ await seedCorpus(store);
288
+ const queryNotes = toolset("v", ["mine"])("query-notes");
289
+
290
+ const probe: any = await queryNotes.execute({ tag: OUT_OF_SCOPE });
291
+ const control: any = await queryNotes.execute({ tag: NONEXISTENT });
292
+
293
+ expect(probe).toEqual(control);
294
+ expect(paths(probe)).toEqual([]);
295
+ expect(JSON.stringify(probe)).not.toContain(OUT_OF_SCOPE);
296
+ });
297
+
298
+ test("query-notes { tag } UNSCOPED control — the co-tagged note still comes back", async () => {
299
+ const store = seedVault("v");
300
+ await seedCorpus(store);
301
+
302
+ const probe: any = await toolset("v", null)("query-notes").execute({ tag: OUT_OF_SCOPE });
303
+ expect(paths(probe)).toEqual(["CoTagged", "HiddenOnly"]);
304
+ });
305
+
306
+ test("query-notes { tag: [in, out], tag_match: all } — matches nothing", async () => {
307
+ const store = seedVault("v");
308
+ await seedCorpus(store);
309
+ const queryNotes = toolset("v", ["mine"])("query-notes");
310
+
311
+ const probe: any = await queryNotes.execute({ tag: ["mine", OUT_OF_SCOPE], tag_match: "all" });
312
+ const control: any = await queryNotes.execute({ tag: ["mine", NONEXISTENT], tag_match: "all" });
313
+
314
+ expect(probe).toEqual(control);
315
+ expect(paths(probe)).toEqual([]);
316
+ });
317
+
318
+ test("query-notes { tag: [in, out], tag_match: any } — the in-scope member is unaffected", async () => {
319
+ const store = seedVault("v");
320
+ await seedCorpus(store);
321
+ await store.createNote("ops co-tagged", { path: "OpsCoTagged", tags: ["ops", OUT_OF_SCOPE] });
322
+ const queryNotes = toolset("v", ["mine", "ops"])("query-notes");
323
+
324
+ const probe: any = await queryNotes.execute({ tag: ["mine", OUT_OF_SCOPE], tag_match: "any" });
325
+ const control: any = await queryNotes.execute({ tag: ["mine", NONEXISTENT], tag_match: "any" });
326
+
327
+ expect(probe).toEqual(control);
328
+ expect(paths(probe)).toEqual(["CoTagged", "MineOnly"]);
329
+ });
330
+
331
+ test("query-notes { exclude_tags } — excludes nothing", async () => {
332
+ const store = seedVault("v");
333
+ await seedCorpus(store);
334
+ const queryNotes = toolset("v", ["mine"])("query-notes");
335
+
336
+ const probe: any = await queryNotes.execute({ exclude_tags: OUT_OF_SCOPE });
337
+ const control: any = await queryNotes.execute({ exclude_tags: NONEXISTENT });
338
+
339
+ expect(probe).toEqual(control);
340
+ expect(paths(probe)).toEqual(["CoTagged", "MineOnly"]);
341
+ });
342
+
343
+ test("query-notes aggregate rollup — no counting oracle on this door either", async () => {
344
+ const store = seedVault("v");
345
+ await seedCorpus(store);
346
+ const queryNotes = toolset("v", ["mine"])("query-notes");
347
+
348
+ const probe: any = await queryNotes.execute({ tag: OUT_OF_SCOPE, aggregate: { op: "count" } });
349
+ const control: any = await queryNotes.execute({ tag: NONEXISTENT, aggregate: { op: "count" } });
350
+
351
+ expect(probe).toEqual(control);
352
+ expect(probe).toEqual([{ group: null, value: 0 }]);
353
+ });
354
+
355
+ test("an IN-SCOPE query tag is untouched on this door too", async () => {
356
+ const store = seedVault("v");
357
+ await seedCorpus(store);
358
+ const queryNotes = toolset("v", ["mine"])("query-notes");
359
+
360
+ expect(paths(await queryNotes.execute({ tag: "mine" }))).toEqual(["CoTagged", "MineOnly"]);
361
+ expect(paths(await queryNotes.execute({ tag: "#mine" }))).toEqual(["CoTagged", "MineOnly"]);
362
+ });
363
+
364
+ test("BOTH-DOOR PARITY — REST and MCP answer an out-of-scope query tag the same way", async () => {
365
+ const store = seedVault("v");
366
+ await seedCorpus(store);
367
+
368
+ const restBody = (await rest(store, await restScope(store, ["mine"]), `?tag=${OUT_OF_SCOPE}`)).body;
369
+ const mcpBody: any = await toolset("v", ["mine"])("query-notes").execute({ tag: OUT_OF_SCOPE });
370
+
371
+ expect(paths(mcpBody)).toEqual(paths(restBody));
372
+ expect(paths(restBody)).toEqual([]);
373
+ });
374
+ });
package/src/tag-scope.ts CHANGED
@@ -23,6 +23,7 @@ import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.t
23
23
  import type { TagFieldViolation } from "../core/src/tag-schemas.ts";
24
24
  import { ParentCycleError } from "../core/src/tag-schemas.ts";
25
25
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
26
+ import { stripTagHash } from "../core/src/tag-hierarchy.ts";
26
27
 
27
28
  /** Generic replacement for a redacted out-of-scope tag name — never the real name. */
28
29
  const OUT_OF_SCOPE_LABEL = "(outside your token's tag scope)";
@@ -246,6 +247,121 @@ export function scrubNotesTagsByScope<T extends { tags?: string[] }>(
246
247
  return notes.map((n) => scrubNoteTagsByScope(n, allowed, rawRoots));
247
248
  }
248
249
 
250
+ /**
251
+ * The stand-in an out-of-scope QUERY tag is rewritten to (vault#675) — a
252
+ * name no real tagging workflow can produce, so a filter naming it matches
253
+ * nothing. See `scopeQueryTags` for why that is the whole fix.
254
+ *
255
+ * Two properties are load-bearing, both pinned by
256
+ * `tag-scope-query-tag.test.ts`:
257
+ *
258
+ * 1. **It survives `stripTagHash`.** `SqliteStore.normalizeQueryTags` maps
259
+ * `stripTagHash` over query tags and drops the empties — a substitute
260
+ * that normalized away would collapse `tags` to `[]`, skip the tag
261
+ * filter entirely, and hand an out-of-scope probe the caller's WHOLE
262
+ * visible set. Hence the NUL rather than a leading `#`/space.
263
+ * 2. **A collision is inert.** Storage does not police tag bytes, so a
264
+ * note CAN be written carrying this name (nothing that reads YAML
265
+ * front-matter or a JSON `tags` array will do so by accident). It buys
266
+ * an attacker nothing: this rewrite only changes which notes the query
267
+ * matches, and every read path still applies the unchanged result-side
268
+ * `filterNotesByTagScope` — so a planted decoy can only ever surface to
269
+ * a caller who could already read it.
270
+ */
271
+ export const OUT_OF_SCOPE_QUERY_TAG = "\u0000out-of-scope";
272
+
273
+ /**
274
+ * Rewrite an out-of-scope tag NAMED IN A QUERY to `OUT_OF_SCOPE_QUERY_TAG`
275
+ * (vault#675 — the existence-oracle class left open after #568/#674).
276
+ *
277
+ * #568 stopped scoped reads from DISCLOSING an out-of-scope co-tag's name.
278
+ * A scoped caller could still name one itself — `?tag=project-manhattan`,
279
+ * `query-notes { tag }`, a live subscription — and read the answer off
280
+ * hit/miss: a note tagged `["mine","project-manhattan"]` passes
281
+ * `noteWithinTagScope` via `mine`, so the co-tagged filter came back with a
282
+ * row and confirmed the guessed name. No name leaked; membership did.
283
+ *
284
+ * Policy: **an out-of-scope tag behaves exactly as a tag that does not
285
+ * exist** — the same equivalence the rest of this contract already runs on
286
+ * (an out-of-scope note 404s like a missing one; `list-tags { tag }` /
287
+ * `GET /api/tags?tag=` return `tag_not_found` for an out-of-scope name
288
+ * "whether the tag exists or not"). In the FILTER position a nonexistent tag
289
+ * is not an error — it is a filter that matches nothing — so this is a
290
+ * rewrite, not a rejection: the query still runs and still returns 200, it
291
+ * just can't match. Rewriting (rather than short-circuiting per door) is
292
+ * what makes the response byte-identical to the nonexistent-tag control on
293
+ * every door and in every shape — list, cursor envelope, `format=graph`,
294
+ * aggregate rollup, live snapshot — because it IS the nonexistent-tag code
295
+ * path, not a reconstruction of it.
296
+ *
297
+ * Composition falls out of that, no special cases:
298
+ * - `tag=X` (out of scope) → matches nothing → `[]`.
299
+ * - `tag=mine&tag=X&tag_match=all` → the `X` membership clause matches
300
+ * nothing → `[]` (dropping `X` instead would have WIDENED the answer).
301
+ * - `tag=mine&tag=X&tag_match=any` → the union is just `mine`'s notes.
302
+ * - `exclude_tag=X` → core skips an exclude clause that can't match,
303
+ * so it excludes nothing — again exactly a nonexistent tag.
304
+ *
305
+ * Visibility uses the same `tagVisibleInScope` rule that admits a note and
306
+ * scrubs `.tags`, applied to the BARE form (`stripTagHash`) because the
307
+ * query engine strips `#` before matching — otherwise `?tag=%23mine` would
308
+ * be neutralised for a `mine`-scoped caller.
309
+ *
310
+ * Non-mutating, and a no-op for unscoped tokens (`rawRoots === null`) and
311
+ * for queries whose tags are all in scope — the input object is returned by
312
+ * reference in both cases.
313
+ */
314
+ export function scopeQueryTags<T extends { tags?: string[]; excludeTags?: string[] }>(
315
+ opts: T,
316
+ allowed: Set<string> | null,
317
+ rawRoots: string[] | null,
318
+ ): T {
319
+ if (rawRoots === null || !opts) return opts;
320
+ const tags = scopeQueryTagList(opts.tags, allowed, rawRoots);
321
+ const excludeTags = scopeQueryTagList(opts.excludeTags, allowed, rawRoots);
322
+ if (tags === opts.tags && excludeTags === opts.excludeTags) return opts;
323
+ const next: T = { ...opts };
324
+ if (tags !== opts.tags) next.tags = tags;
325
+ if (excludeTags !== opts.excludeTags) next.excludeTags = excludeTags;
326
+ return next;
327
+ }
328
+
329
+ /** `scopeQueryTags` for one array of query tags. Same array back if unchanged. */
330
+ function scopeQueryTagList(
331
+ tags: string[] | undefined,
332
+ allowed: Set<string> | null,
333
+ rawRoots: string[] | null,
334
+ ): string[] | undefined {
335
+ if (!tags || tags.length === 0) return tags;
336
+ let changed = false;
337
+ const out = tags.map((t) => {
338
+ if (typeof t !== "string" || tagVisibleInScope(stripTagHash(t), allowed, rawRoots)) return t;
339
+ changed = true;
340
+ return OUT_OF_SCOPE_QUERY_TAG;
341
+ });
342
+ return changed ? out : tags;
343
+ }
344
+
345
+ /**
346
+ * `scopeQueryTags` for an MCP tool param, which is `string | string[]`
347
+ * (`normalizeTags` accepts either). Shape-preserving — a string stays a
348
+ * string — so the rewritten params lower to the same `QueryOpts` the
349
+ * original would have. Same reference back when nothing is rewritten.
350
+ */
351
+ export function scopeQueryTagParam<V>(value: V, allowed: Set<string> | null, rawRoots: string[] | null): V {
352
+ if (rawRoots === null || value === undefined || value === null) return value;
353
+ if (typeof value === "string") {
354
+ return (tagVisibleInScope(stripTagHash(value), allowed, rawRoots)
355
+ ? value
356
+ : OUT_OF_SCOPE_QUERY_TAG) as unknown as V;
357
+ }
358
+ if (Array.isArray(value)) {
359
+ const scoped = scopeQueryTagList(value as string[], allowed, rawRoots);
360
+ return (scoped === (value as unknown as string[]) ? value : scoped) as unknown as V;
361
+ }
362
+ return value;
363
+ }
364
+
249
365
  /**
250
366
  * Treat a hydrated link's endpoint summary as a scope-checkable note. The
251
367
  * summary carries `id` + `tags`, which is all `noteWithinTagScope` needs.
@@ -0,0 +1,58 @@
1
+ {
2
+ "MCP": {
3
+ "path": [
4
+ "work-src",
5
+ "hidden-dup"
6
+ ],
7
+ "relationships": [
8
+ "wikilink"
9
+ ],
10
+ "nodes": [
11
+ {
12
+ "id": "work-src",
13
+ "path": "work-src"
14
+ },
15
+ {
16
+ "id": "hidden-dup",
17
+ "path": "p1/Dup"
18
+ }
19
+ ],
20
+ "edges": [
21
+ {
22
+ "source": "work-src",
23
+ "target": "hidden-dup",
24
+ "relationship": "wikilink",
25
+ "sourcePath": "work-src",
26
+ "targetPath": "p1/Dup"
27
+ }
28
+ ]
29
+ },
30
+ "REST": {
31
+ "path": [
32
+ "work-src",
33
+ "hidden-dup"
34
+ ],
35
+ "relationships": [
36
+ "wikilink"
37
+ ],
38
+ "nodes": [
39
+ {
40
+ "id": "work-src",
41
+ "path": "work-src"
42
+ },
43
+ {
44
+ "id": "hidden-dup",
45
+ "path": "p1/Dup"
46
+ }
47
+ ],
48
+ "edges": [
49
+ {
50
+ "source": "work-src",
51
+ "target": "hidden-dup",
52
+ "relationship": "wikilink",
53
+ "sourcePath": "work-src",
54
+ "targetPath": "p1/Dup"
55
+ }
56
+ ]
57
+ }
58
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "REST": {
3
+ "nodes": [
4
+ {
5
+ "id": "hidden-dup",
6
+ "path": "p1/Dup",
7
+ "tags": [
8
+ "personal"
9
+ ]
10
+ },
11
+ {
12
+ "id": "work-src",
13
+ "path": "work-src",
14
+ "tags": [
15
+ "work"
16
+ ]
17
+ }
18
+ ],
19
+ "edges": [
20
+ {
21
+ "source": "work-src",
22
+ "target": "hidden-dup",
23
+ "relationship": "wikilink"
24
+ }
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,96 @@
1
+ {
2
+ "MCP": [
3
+ [
4
+ {
5
+ "id": "hidden-dup",
6
+ "path": "p1/Dup",
7
+ "extension": "md",
8
+ "createdAt": "2026-01-01T00:00:00.000Z",
9
+ "updatedAt": "2026-01-01T00:00:00.000Z",
10
+ "createdBy": null,
11
+ "createdVia": null,
12
+ "lastUpdatedBy": null,
13
+ "lastUpdatedVia": null,
14
+ "tags": [
15
+ "personal"
16
+ ],
17
+ "metadata": {},
18
+ "byteSize": 12,
19
+ "preview": "personal dup",
20
+ "displayTitle": "personal dup"
21
+ },
22
+ {
23
+ "id": "work-src",
24
+ "path": "work-src",
25
+ "extension": "md",
26
+ "createdAt": "2026-01-01T00:00:00.000Z",
27
+ "updatedAt": "2026-01-01T00:00:00.000Z",
28
+ "createdBy": null,
29
+ "createdVia": null,
30
+ "lastUpdatedBy": null,
31
+ "lastUpdatedVia": null,
32
+ "tags": [
33
+ "work"
34
+ ],
35
+ "metadata": {},
36
+ "byteSize": 11,
37
+ "preview": "src [[Dup]]",
38
+ "displayTitle": "src [[Dup]]"
39
+ }
40
+ ],
41
+ [],
42
+ [
43
+ {
44
+ "group": null,
45
+ "value": 2
46
+ }
47
+ ]
48
+ ],
49
+ "REST": [
50
+ [
51
+ {
52
+ "id": "hidden-dup",
53
+ "path": "p1/Dup",
54
+ "extension": "md",
55
+ "createdAt": "2026-01-01T00:00:00.000Z",
56
+ "updatedAt": "2026-01-01T00:00:00.000Z",
57
+ "createdBy": null,
58
+ "createdVia": null,
59
+ "lastUpdatedBy": null,
60
+ "lastUpdatedVia": null,
61
+ "tags": [
62
+ "personal"
63
+ ],
64
+ "metadata": {},
65
+ "byteSize": 12,
66
+ "preview": "personal dup",
67
+ "displayTitle": "personal dup"
68
+ },
69
+ {
70
+ "id": "work-src",
71
+ "path": "work-src",
72
+ "extension": "md",
73
+ "createdAt": "2026-01-01T00:00:00.000Z",
74
+ "updatedAt": "2026-01-01T00:00:00.000Z",
75
+ "createdBy": null,
76
+ "createdVia": null,
77
+ "lastUpdatedBy": null,
78
+ "lastUpdatedVia": null,
79
+ "tags": [
80
+ "work"
81
+ ],
82
+ "metadata": {},
83
+ "byteSize": 11,
84
+ "preview": "src [[Dup]]",
85
+ "displayTitle": "src [[Dup]]"
86
+ }
87
+ ],
88
+ [],
89
+ [
90
+ {
91
+ "group": null,
92
+ "value": 2
93
+ }
94
+ ]
95
+ ]
96
+ }