@openparachute/vault 0.7.0-rc.3 → 0.7.0-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/contract-typed-index.test.ts +92 -3
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +66 -42
- package/core/src/notes.ts +9 -0
- package/core/src/tag-schemas.ts +186 -4
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +109 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +48 -4
- package/src/routes.ts +276 -78
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined tag-scope × full-text-search enforcement (vault#554 carry-forward
|
|
3
|
+
* — "auth-561" review traced the code clean; this commits the self-verifying
|
|
4
|
+
* test the review promised).
|
|
5
|
+
*
|
|
6
|
+
* `applyTagScopeWrappers`'s `query-notes` wrapper (`src/mcp-tools.ts`) runs
|
|
7
|
+
* the ENTIRE search — content matching, `search_mode` escaping, `sort` — via
|
|
8
|
+
* core (scope-unaware by architecture), then filters the returned notes
|
|
9
|
+
* against the token's allowlist as a post-hoc array `.filter()`
|
|
10
|
+
* (`noteWithinTagScope`, `src/tag-scope.ts`). Because the filter is applied
|
|
11
|
+
* uniformly AFTER core returns, in principle no single (mode, sort)
|
|
12
|
+
* combination is special — but a regression in one code path (e.g. a search
|
|
13
|
+
* branch that bypasses the wrapper, or a shape the filter doesn't recognize)
|
|
14
|
+
* could still slip an out-of-scope note through for SOME combination. This
|
|
15
|
+
* test exercises the full `search_mode × sort` matrix and asserts the
|
|
16
|
+
* out-of-scope note never appears in any of them.
|
|
17
|
+
*
|
|
18
|
+
* Fixture: two notes both match the search term "widget" in punctuation-
|
|
19
|
+
* bearing content (so literal-mode escaping and advanced-mode raw FTS5 both
|
|
20
|
+
* still match it) — one tagged in-scope (`health`), one out-of-scope
|
|
21
|
+
* (`work`). A tag-scoped session ["health"] must see only the in-scope note
|
|
22
|
+
* across every (search_mode, sort) pairing.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
25
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
26
|
+
import { join } from "path";
|
|
27
|
+
import { tmpdir } from "os";
|
|
28
|
+
import { writeVaultConfig } from "./config.ts";
|
|
29
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
30
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
31
|
+
import type { AuthResult } from "./auth.ts";
|
|
32
|
+
import { SEARCH_MODES } from "../core/src/search-query.ts";
|
|
33
|
+
|
|
34
|
+
let tmpHome: string;
|
|
35
|
+
let prevHome: string | undefined;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tmpHome = join(tmpdir(), `vault-search-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
39
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
40
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
41
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
42
|
+
clearVaultStoreCache();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
clearVaultStoreCache();
|
|
47
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
48
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
49
|
+
if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function seedVault(name: string): void {
|
|
53
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
54
|
+
getVaultStore(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
|
|
58
|
+
return {
|
|
59
|
+
permission: "full",
|
|
60
|
+
scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
|
|
61
|
+
legacyDerived: false,
|
|
62
|
+
scoped_tags: scopedTags,
|
|
63
|
+
vault_name: null,
|
|
64
|
+
caller_jti: null,
|
|
65
|
+
actor: "test-user",
|
|
66
|
+
via: "api",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function queryNotesTool(vaultName: string, scopedTags: string[] | null) {
|
|
71
|
+
const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
|
|
72
|
+
return tools.find((t) => t.name === "query-notes")!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Normalize the tool's response (bare array, or {notes, next_cursor?,
|
|
76
|
+
// warnings?} in cursor/warnings mode) down to the note-id list actually
|
|
77
|
+
// returned — search is never cursor mode, but stays defensive to shape.
|
|
78
|
+
function idsOf(result: unknown): string[] {
|
|
79
|
+
if (Array.isArray(result)) return result.map((n: any) => n.id);
|
|
80
|
+
const notes = (result as any)?.notes;
|
|
81
|
+
return Array.isArray(notes) ? notes.map((n: any) => n.id) : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("MCP query-notes search × tag-scope — combined enforcement (vault#554 carry-forward)", () => {
|
|
85
|
+
test("out-of-scope notes never appear across every search_mode × sort combination", async () => {
|
|
86
|
+
seedVault("journal");
|
|
87
|
+
const store = getVaultStore("journal");
|
|
88
|
+
|
|
89
|
+
// Punctuation-bearing content so literal-mode's escaping and
|
|
90
|
+
// advanced-mode's raw FTS5 passthrough both still match "widget"
|
|
91
|
+
// cleanly (advanced mode has no operator characters to trip on here).
|
|
92
|
+
const inScope = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
|
|
93
|
+
const outOfScope = await store.createNote("ordered a replacement widget", { tags: ["work"] });
|
|
94
|
+
|
|
95
|
+
const tool = await queryNotesTool("journal", ["health"]);
|
|
96
|
+
|
|
97
|
+
const sorts: (undefined | "asc" | "desc")[] = [undefined, "asc", "desc"];
|
|
98
|
+
const modes: (undefined | (typeof SEARCH_MODES)[number])[] = [undefined, ...SEARCH_MODES];
|
|
99
|
+
|
|
100
|
+
for (const search_mode of modes) {
|
|
101
|
+
for (const sort of sorts) {
|
|
102
|
+
const params: Record<string, unknown> = { search: "widget" };
|
|
103
|
+
if (search_mode !== undefined) params.search_mode = search_mode;
|
|
104
|
+
if (sort !== undefined) params.sort = sort;
|
|
105
|
+
|
|
106
|
+
const result = await tool.execute(params);
|
|
107
|
+
const ids = idsOf(result);
|
|
108
|
+
|
|
109
|
+
expect(ids).toContain(inScope.id);
|
|
110
|
+
expect(ids).not.toContain(outOfScope.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("unscoped session sees both notes across the same matrix (control)", async () => {
|
|
116
|
+
seedVault("journal");
|
|
117
|
+
const store = getVaultStore("journal");
|
|
118
|
+
|
|
119
|
+
const a = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
|
|
120
|
+
const b = await store.createNote("ordered a replacement widget", { tags: ["work"] });
|
|
121
|
+
|
|
122
|
+
const tool = await queryNotesTool("journal", null);
|
|
123
|
+
|
|
124
|
+
for (const search_mode of [undefined, ...SEARCH_MODES]) {
|
|
125
|
+
for (const sort of [undefined, "asc", "desc"] as const) {
|
|
126
|
+
const params: Record<string, unknown> = { search: "widget" };
|
|
127
|
+
if (search_mode !== undefined) params.search_mode = search_mode;
|
|
128
|
+
if (sort !== undefined) params.sort = sort;
|
|
129
|
+
|
|
130
|
+
const ids = idsOf(await tool.execute(params));
|
|
131
|
+
expect(ids).toContain(a.id);
|
|
132
|
+
expect(ids).toContain(b.id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/mcp-tools.ts
CHANGED
|
@@ -22,8 +22,12 @@ import {
|
|
|
22
22
|
expandTokenTagScope,
|
|
23
23
|
filterHydratedLinksByTagScope,
|
|
24
24
|
noteWithinTagScope,
|
|
25
|
+
scrubIndexedFieldConflictError,
|
|
26
|
+
scrubTagFieldViolationsByScope,
|
|
25
27
|
tagsWithinScope,
|
|
26
28
|
} from "./tag-scope.ts";
|
|
29
|
+
import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
|
|
30
|
+
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
27
31
|
import {
|
|
28
32
|
findTokensReferencingTag,
|
|
29
33
|
recordMcpMintLedger,
|
|
@@ -344,7 +348,7 @@ function applyTagScopeWrappers(
|
|
|
344
348
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
345
349
|
return noteWithinTagScope(result as any, allowed, rawTags)
|
|
346
350
|
? scrubNoteLinks(result)
|
|
347
|
-
: { error: "Note not found", id: (result as any).id };
|
|
351
|
+
: { error: "Note not found", error_type: "not_found", id: (result as any).id };
|
|
348
352
|
}
|
|
349
353
|
return result;
|
|
350
354
|
});
|
|
@@ -470,7 +474,7 @@ function applyTagScopeWrappers(
|
|
|
470
474
|
if (!id) continue;
|
|
471
475
|
const existing = await store.getNote(id as string);
|
|
472
476
|
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
473
|
-
return { error: "Note not found", id };
|
|
477
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
474
478
|
}
|
|
475
479
|
const removed = new Set<string>((item as any).tags?.remove ?? []);
|
|
476
480
|
const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
|
|
@@ -489,7 +493,7 @@ function applyTagScopeWrappers(
|
|
|
489
493
|
if (id) {
|
|
490
494
|
const existing = await store.getNote(id as string);
|
|
491
495
|
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
492
|
-
return { error: "Note not found", id };
|
|
496
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
493
497
|
}
|
|
494
498
|
}
|
|
495
499
|
return await orig(params);
|
|
@@ -502,7 +506,37 @@ function applyTagScopeWrappers(
|
|
|
502
506
|
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
503
507
|
return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
|
|
504
508
|
}
|
|
505
|
-
|
|
509
|
+
try {
|
|
510
|
+
return await orig(params);
|
|
511
|
+
} catch (err: any) {
|
|
512
|
+
// Tag-scope scrub for cross-tag field conflicts (vault#554
|
|
513
|
+
// auth-and-scope fold). Core's validation scans EVERY tag's schema
|
|
514
|
+
// (scope-unaware by architecture), so its TagFieldConflictError can
|
|
515
|
+
// name an OUT-OF-SCOPE tag and reveal its declared type/flag in both
|
|
516
|
+
// the violation entries and the error message. The write stays
|
|
517
|
+
// rejected — schema integrity is scope-independent — but re-throw
|
|
518
|
+
// with scrubbed violations (out-of-scope declarers generalized, no
|
|
519
|
+
// tag name / declared type, `other_tag` dropped; in-scope declarers
|
|
520
|
+
// keep full detail). Rebuilding via the constructor also rebuilds
|
|
521
|
+
// the top-level message from the scrubbed entries. Same pattern as
|
|
522
|
+
// the list-tags `did_you_mean` scrub above.
|
|
523
|
+
if (err && err.code === "TAG_FIELD_CONFLICT" && Array.isArray(err.violations)) {
|
|
524
|
+
throw new TagFieldConflictError(
|
|
525
|
+
err.tag ?? (tag as string),
|
|
526
|
+
scrubTagFieldViolationsByScope(err.violations, allowed),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
// Same leak through the OTHER door (wire-review interaction): a
|
|
530
|
+
// both-indexed cross-tag type conflict deliberately bypasses the
|
|
531
|
+
// pre-check (preserving its declareField → invalid_indexed_field
|
|
532
|
+
// contract), and declareField's IndexedFieldError message names the
|
|
533
|
+
// other declarer tag(s). Generalize for scoped callers; solo
|
|
534
|
+
// own-field IndexedFieldErrors (no declarer_tags) pass untouched.
|
|
535
|
+
if (err instanceof IndexedFieldError) {
|
|
536
|
+
throw scrubIndexedFieldConflictError(err, allowed);
|
|
537
|
+
}
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
506
540
|
});
|
|
507
541
|
|
|
508
542
|
wrapReadTool(tools, "delete-tag", async (orig, params) => {
|
|
@@ -542,6 +576,16 @@ function overrideVaultInfo(
|
|
|
542
576
|
if (!vaultInfo) return;
|
|
543
577
|
|
|
544
578
|
vaultInfo.execute = async (params) => {
|
|
579
|
+
// NOTE (vault#554): these two throws are deliberately left as plain,
|
|
580
|
+
// unstructured `Error`s — NOT given `error_type` — even though the rest
|
|
581
|
+
// of this file's sweep attaches one everywhere else. Attaching one here
|
|
582
|
+
// routes the throw through mcp-http.ts's structured-error mapping, which
|
|
583
|
+
// surfaces it as a JSON-RPC protocol-level error (`response.error`)
|
|
584
|
+
// instead of the in-band tool result the existing contract test asserts
|
|
585
|
+
// (`response.result.isError === true`, `response.result.content[0].text`
|
|
586
|
+
// containing the scope name) — see "tools/call of vault-info with
|
|
587
|
+
// description arg and vault:read scope is refused" in src/vault.test.ts.
|
|
588
|
+
// Changing that transport shape is out of scope for this wave.
|
|
545
589
|
const config = readVaultConfig(vaultName);
|
|
546
590
|
if (!config) throw new Error(`Vault "${vaultName}" not found`);
|
|
547
591
|
|