@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.
- package/core/src/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- 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
|
+
}
|