@openparachute/vault 0.5.2 → 0.5.3-rc.2
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 +89 -0
- package/core/src/links.ts +19 -1
- package/core/src/mcp.ts +60 -20
- package/core/src/types.ts +10 -0
- package/package.json +1 -1
- package/src/config.test.ts +66 -0
- package/src/config.ts +31 -10
- package/src/mcp-tools.ts +20 -1
- package/src/routes.ts +52 -24
- package/src/routing.test.ts +192 -1
- package/src/routing.ts +32 -1
- package/src/vault.test.ts +40 -0
- package/web/ui/dist/assets/{index-D8nCVT1e.js → index-DJL6Az--.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/core.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { SqliteStore } from "./store.js";
|
|
|
4
4
|
import { generateMcpTools } from "./mcp.js";
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
|
+
import { traverseLinks } from "./links.js";
|
|
7
8
|
|
|
8
9
|
let store: SqliteStore;
|
|
9
10
|
let db: Database;
|
|
@@ -1894,6 +1895,59 @@ describe("links", async () => {
|
|
|
1894
1895
|
const links = await store.getLinks("a");
|
|
1895
1896
|
expect(links.filter((l) => l.relationship === "mentions")).toHaveLength(1);
|
|
1896
1897
|
});
|
|
1898
|
+
|
|
1899
|
+
// vault#439 — traverseLinks isTraversable predicate (wall, not sieve).
|
|
1900
|
+
// Topology: a -> b(blocked) -> c. A predicate that blocks `b` must make
|
|
1901
|
+
// `c` unreachable (the BFS can't walk THROUGH b), not merely filtered out.
|
|
1902
|
+
it("traverseLinks: isTraversable predicate is a wall (can't reach past a blocked hop)", async () => {
|
|
1903
|
+
await store.createNote("A", { id: "a" });
|
|
1904
|
+
await store.createNote("B", { id: "b" });
|
|
1905
|
+
await store.createNote("C", { id: "c" });
|
|
1906
|
+
await store.createLink("a", "b", "relates");
|
|
1907
|
+
await store.createLink("b", "c", "relates");
|
|
1908
|
+
|
|
1909
|
+
const blocked = traverseLinks(db, "a", {
|
|
1910
|
+
max_depth: 5,
|
|
1911
|
+
isTraversable: (id) => id !== "b",
|
|
1912
|
+
});
|
|
1913
|
+
const ids = blocked.map((t) => t.noteId);
|
|
1914
|
+
expect(ids).not.toContain("b"); // blocked hop is excluded from results
|
|
1915
|
+
expect(ids).not.toContain("c"); // and unreachable beyond it
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it("traverseLinks: no predicate walks the full graph (unchanged)", async () => {
|
|
1919
|
+
await store.createNote("A", { id: "a" });
|
|
1920
|
+
await store.createNote("B", { id: "b" });
|
|
1921
|
+
await store.createNote("C", { id: "c" });
|
|
1922
|
+
await store.createLink("a", "b", "relates");
|
|
1923
|
+
await store.createLink("b", "c", "relates");
|
|
1924
|
+
|
|
1925
|
+
const all = traverseLinks(db, "a", { max_depth: 5 });
|
|
1926
|
+
const ids = all.map((t) => t.noteId);
|
|
1927
|
+
expect(ids).toContain("b");
|
|
1928
|
+
expect(ids).toContain("c");
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it("traverseLinks: an allowed alternate path still reaches the far node", async () => {
|
|
1932
|
+
// a -> b(blocked) -> d ; a -> c(allowed) -> d. d reachable via c.
|
|
1933
|
+
await store.createNote("A", { id: "a" });
|
|
1934
|
+
await store.createNote("B", { id: "b" });
|
|
1935
|
+
await store.createNote("C", { id: "c" });
|
|
1936
|
+
await store.createNote("D", { id: "d" });
|
|
1937
|
+
await store.createLink("a", "b", "relates");
|
|
1938
|
+
await store.createLink("b", "d", "relates");
|
|
1939
|
+
await store.createLink("a", "c", "relates");
|
|
1940
|
+
await store.createLink("c", "d", "relates");
|
|
1941
|
+
|
|
1942
|
+
const res = traverseLinks(db, "a", {
|
|
1943
|
+
max_depth: 5,
|
|
1944
|
+
isTraversable: (id) => id !== "b",
|
|
1945
|
+
});
|
|
1946
|
+
const ids = res.map((t) => t.noteId);
|
|
1947
|
+
expect(ids).not.toContain("b");
|
|
1948
|
+
expect(ids).toContain("c");
|
|
1949
|
+
expect(ids).toContain("d"); // reachable via the allowed c-path
|
|
1950
|
+
});
|
|
1897
1951
|
});
|
|
1898
1952
|
|
|
1899
1953
|
// ---- Attachments ----
|
|
@@ -2013,6 +2067,41 @@ describe("MCP tools", async () => {
|
|
|
2013
2067
|
expect(result[1].tags).toContain("doc");
|
|
2014
2068
|
});
|
|
2015
2069
|
|
|
2070
|
+
// vault#316 — the create-note tool re-reads each note AFTER
|
|
2071
|
+
// `applySchemaDefaults` runs, so the response reflects the post-defaults
|
|
2072
|
+
// on-disk state (matching the update-note path). Before the fix the
|
|
2073
|
+
// response mapped over the pre-defaults in-memory objects, so a
|
|
2074
|
+
// schema-default-filled field was missing from the returned note even
|
|
2075
|
+
// though it had just been written to disk.
|
|
2076
|
+
it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
|
|
2077
|
+
await store.upsertTagSchema("task", {
|
|
2078
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2079
|
+
});
|
|
2080
|
+
const tools = generateMcpTools(store);
|
|
2081
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2082
|
+
|
|
2083
|
+
// Single: default lands in the returned metadata.
|
|
2084
|
+
const single = await createNote.execute({
|
|
2085
|
+
content: "do the thing",
|
|
2086
|
+
path: "Inbox/task-1",
|
|
2087
|
+
tags: ["task"],
|
|
2088
|
+
}) as any;
|
|
2089
|
+
expect(single.metadata?.priority).toBe("high"); // first enum value
|
|
2090
|
+
// Disk and response agree.
|
|
2091
|
+
const onDisk = await store.getNoteByPath("Inbox/task-1");
|
|
2092
|
+
expect((onDisk!.metadata as any)?.priority).toBe("high");
|
|
2093
|
+
|
|
2094
|
+
// Batch: each entry is re-read post-defaults too.
|
|
2095
|
+
const batch = await createNote.execute({
|
|
2096
|
+
notes: [
|
|
2097
|
+
{ content: "a", path: "Inbox/task-2", tags: ["task"] },
|
|
2098
|
+
{ content: "b", path: "Inbox/task-3", tags: ["task"] },
|
|
2099
|
+
],
|
|
2100
|
+
}) as any[];
|
|
2101
|
+
expect(batch[0].metadata?.priority).toBe("high");
|
|
2102
|
+
expect(batch[1].metadata?.priority).toBe("high");
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2016
2105
|
it("create-note accepts extension field (vault#328)", async () => {
|
|
2017
2106
|
const tools = generateMcpTools(store);
|
|
2018
2107
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
package/core/src/links.ts
CHANGED
|
@@ -255,14 +255,26 @@ export interface TraversalNode {
|
|
|
255
255
|
/**
|
|
256
256
|
* Traverse the link graph from a starting note up to `maxDepth` hops.
|
|
257
257
|
* Returns all reachable notes with their depth and how they were reached.
|
|
258
|
+
*
|
|
259
|
+
* `isTraversable` (vault#439) is an OPTIONAL per-note predicate. When
|
|
260
|
+
* provided, a neighbor that fails the predicate is treated as a WALL: it is
|
|
261
|
+
* neither added to the results nor pushed onto the frontier, so the BFS
|
|
262
|
+
* cannot reach further notes THROUGH it. This makes a tag-scoped traversal
|
|
263
|
+
* symmetric with `find-path` (which guards every hop) — scope acts as a wall,
|
|
264
|
+
* not a sieve. Omitted (every unscoped / internal caller) → the full graph is
|
|
265
|
+
* walked exactly as before. Core stays scope-unaware: it receives a plain
|
|
266
|
+
* `(noteId) => boolean` closure and never imports the server's tag-scope
|
|
267
|
+
* module. The anchor `noteId` is never passed through the predicate — the
|
|
268
|
+
* caller is responsible for confirming the anchor is in scope before calling.
|
|
258
269
|
*/
|
|
259
270
|
export function traverseLinks(
|
|
260
271
|
db: Database,
|
|
261
272
|
noteId: string,
|
|
262
|
-
opts?: { max_depth?: number; relationship?: string },
|
|
273
|
+
opts?: { max_depth?: number; relationship?: string; isTraversable?: (noteId: string) => boolean },
|
|
263
274
|
): TraversalNode[] {
|
|
264
275
|
const maxDepth = opts?.max_depth ?? 2;
|
|
265
276
|
const relFilter = opts?.relationship;
|
|
277
|
+
const isTraversable = opts?.isTraversable;
|
|
266
278
|
const visited = new Set<string>([noteId]);
|
|
267
279
|
const results: TraversalNode[] = [];
|
|
268
280
|
let frontier = [noteId];
|
|
@@ -286,6 +298,10 @@ export function traverseLinks(
|
|
|
286
298
|
for (const row of outbound) {
|
|
287
299
|
if (!visited.has(row.target_id)) {
|
|
288
300
|
visited.add(row.target_id);
|
|
301
|
+
// Wall (vault#439): an out-of-scope neighbor is marked visited (so
|
|
302
|
+
// it isn't re-evaluated) but is NOT added to the frontier or the
|
|
303
|
+
// results — the BFS can't traverse THROUGH it to reach notes beyond.
|
|
304
|
+
if (isTraversable && !isTraversable(row.target_id)) continue;
|
|
289
305
|
nextFrontier.push(row.target_id);
|
|
290
306
|
results.push({
|
|
291
307
|
noteId: row.target_id,
|
|
@@ -311,6 +327,8 @@ export function traverseLinks(
|
|
|
311
327
|
for (const row of inbound) {
|
|
312
328
|
if (!visited.has(row.source_id)) {
|
|
313
329
|
visited.add(row.source_id);
|
|
330
|
+
// Wall (vault#439): see the outbound branch above.
|
|
331
|
+
if (isTraversable && !isTraversable(row.source_id)) continue;
|
|
314
332
|
nextFrontier.push(row.source_id);
|
|
315
333
|
results.push({
|
|
316
334
|
noteId: row.source_id,
|
package/core/src/mcp.ts
CHANGED
|
@@ -114,6 +114,15 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
114
114
|
*/
|
|
115
115
|
export interface GenerateMcpToolsOpts {
|
|
116
116
|
expandVisibility?: (note: Note) => boolean;
|
|
117
|
+
/**
|
|
118
|
+
* `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
|
|
119
|
+
* into the `near[]` graph BFS. When provided, the traversal refuses to walk
|
|
120
|
+
* THROUGH any note that fails the predicate — making a tag-scoped `near[]`
|
|
121
|
+
* query symmetric with `find-path` (scope is a wall, not a sieve). Core
|
|
122
|
+
* stays scope-unaware: it receives a plain `(noteId) => boolean` closure.
|
|
123
|
+
* Omitted (unscoped / internal callers) → the full graph is walked.
|
|
124
|
+
*/
|
|
125
|
+
nearTraversable?: (noteId: string) => boolean;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
/**
|
|
@@ -122,8 +131,9 @@ export interface GenerateMcpToolsOpts {
|
|
|
122
131
|
* delete-tag, find-path, vault-info, prune-schema (admin).
|
|
123
132
|
*/
|
|
124
133
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
125
|
-
const db: Database =
|
|
134
|
+
const db: Database = store.db;
|
|
126
135
|
const expandVisibility = opts?.expandVisibility;
|
|
136
|
+
const nearTraversable = opts?.nearTraversable;
|
|
127
137
|
|
|
128
138
|
return [
|
|
129
139
|
|
|
@@ -305,15 +315,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
305
315
|
|
|
306
316
|
// --- Build near-scope (graph-filtered set of allowed IDs) ---
|
|
307
317
|
//
|
|
308
|
-
// Tag-scope policy for `near[]` (
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
318
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
319
|
+
// find-path): when the session is tag-scoped the server injects a
|
|
320
|
+
// `nearTraversable` predicate (mcp-tools.ts), and the BFS refuses to
|
|
321
|
+
// walk THROUGH out-of-scope notes — scope is a wall, not a sieve. So a
|
|
322
|
+
// token scoped to ["work"] can't reach an in-scope note at depth 2 via
|
|
323
|
+
// a #personal intermediary at depth 1. Core stays scope-unaware: it
|
|
324
|
+
// only invokes the injected closure. Unscoped sessions pass no
|
|
325
|
+
// predicate → the FULL graph is walked exactly as before. The
|
|
326
|
+
// `applyTagScopeWrappers` result-filter still runs afterward (defense
|
|
327
|
+
// in depth), but the wall makes it redundant for `near[]`.
|
|
317
328
|
let nearScope: Set<string> | null = null;
|
|
318
329
|
if (params.near) {
|
|
319
330
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
@@ -323,6 +334,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
323
334
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
324
335
|
max_depth: depth,
|
|
325
336
|
relationship: near.relationship,
|
|
337
|
+
isTraversable: nearTraversable,
|
|
326
338
|
});
|
|
327
339
|
nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
328
340
|
}
|
|
@@ -579,17 +591,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
579
591
|
throw e;
|
|
580
592
|
}
|
|
581
593
|
|
|
582
|
-
// Apply tag schema effects
|
|
594
|
+
// Apply tag schema effects, then re-read the notes whose metadata was
|
|
595
|
+
// actually default-filled so the response reflects the final on-disk
|
|
596
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
597
|
+
// ran, so default-filled metadata isn't on them yet). This mirrors the
|
|
598
|
+
// update-note path, which already re-reads post-defaults. The re-read
|
|
599
|
+
// is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
|
|
600
|
+
// entirely when no defaults were applied, so the common no-defaults
|
|
601
|
+
// path adds zero extra reads.
|
|
602
|
+
const mutatedIds = new Set<string>();
|
|
583
603
|
for (const note of created) {
|
|
584
604
|
if (note.tags && note.tags.length > 0) {
|
|
585
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
605
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
606
|
+
mutatedIds.add(id);
|
|
607
|
+
}
|
|
586
608
|
}
|
|
587
609
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
610
|
+
const refreshed =
|
|
611
|
+
mutatedIds.size === 0
|
|
612
|
+
? created
|
|
613
|
+
: (() => {
|
|
614
|
+
const byId = new Map(
|
|
615
|
+
noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
|
|
616
|
+
);
|
|
617
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
618
|
+
})();
|
|
619
|
+
|
|
620
|
+
// Attach `validation_status` from any tag's `fields` declaration that
|
|
621
|
+
// applies to this note, against the post-defaults state.
|
|
622
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
593
623
|
return batch ? final : final[0];
|
|
594
624
|
},
|
|
595
625
|
},
|
|
@@ -1394,9 +1424,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1394
1424
|
// Tag schema effects — auto-populate defaults when tags are applied
|
|
1395
1425
|
// ---------------------------------------------------------------------------
|
|
1396
1426
|
|
|
1397
|
-
|
|
1427
|
+
/**
|
|
1428
|
+
* Fill schema-declared default values into the metadata of the given notes
|
|
1429
|
+
* for any field they omitted. Returns the IDs of the notes whose metadata was
|
|
1430
|
+
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1431
|
+
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1432
|
+
* no-defaults path returns an empty array.
|
|
1433
|
+
*/
|
|
1434
|
+
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1398
1435
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
1399
|
-
if (Object.keys(schemas).length === 0) return;
|
|
1436
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
1400
1437
|
|
|
1401
1438
|
const defaults: Record<string, unknown> = {};
|
|
1402
1439
|
for (const tag of tags) {
|
|
@@ -1408,8 +1445,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1408
1445
|
}
|
|
1409
1446
|
}
|
|
1410
1447
|
}
|
|
1411
|
-
if (Object.keys(defaults).length === 0) return;
|
|
1448
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
1412
1449
|
|
|
1450
|
+
const mutated: string[] = [];
|
|
1413
1451
|
for (const noteId of noteIds) {
|
|
1414
1452
|
const note = noteOps.getNote(db, noteId);
|
|
1415
1453
|
if (!note) continue;
|
|
@@ -1425,7 +1463,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1425
1463
|
metadata: { ...existing, ...missing },
|
|
1426
1464
|
skipUpdatedAt: true,
|
|
1427
1465
|
});
|
|
1466
|
+
mutated.push(noteId);
|
|
1428
1467
|
}
|
|
1468
|
+
return mutated;
|
|
1429
1469
|
}
|
|
1430
1470
|
|
|
1431
1471
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
package/core/src/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
1
2
|
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
2
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
3
4
|
|
|
@@ -208,6 +209,15 @@ export interface HydratedLink extends Link {
|
|
|
208
209
|
// ---- Store Interface ----
|
|
209
210
|
|
|
210
211
|
export interface Store {
|
|
212
|
+
/**
|
|
213
|
+
* The underlying `bun:sqlite` handle. Exposed (read-only) so callers that
|
|
214
|
+
* need to run a raw query the Store interface doesn't surface — e.g. the
|
|
215
|
+
* token-table reverse-lookups in routes.ts and MCP tool generation in
|
|
216
|
+
* mcp.ts — can reach it without an `(store as any).db` cast. The concrete
|
|
217
|
+
* `Store` class declares this as `public readonly db: Database`. See vault#242.
|
|
218
|
+
*/
|
|
219
|
+
readonly db: Database;
|
|
220
|
+
|
|
211
221
|
// Notes
|
|
212
222
|
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
|
|
213
223
|
getNote(id: string): Promise<Note | null>;
|
package/package.json
CHANGED
package/src/config.test.ts
CHANGED
|
@@ -213,6 +213,72 @@ describe("config", () => {
|
|
|
213
213
|
expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
+
// ----- vault#234: anchored api_keys field regexes ----------------------
|
|
217
|
+
// The api_keys field regexes (label/scope/key_hash/created_at/last_used_at)
|
|
218
|
+
// used to be unanchored, so a COMMENTED `# scope: read` line matched, and a
|
|
219
|
+
// value-less `scope: ` (trailing space) captured the NEXT field's token
|
|
220
|
+
// (`key_hash`). The writer never emits either shape — only hand-editing
|
|
221
|
+
// reaches these branches — but a malformed scope could silently mis-scope a
|
|
222
|
+
// key. The regexes are now line-anchored + horizontal-whitespace-bounded.
|
|
223
|
+
|
|
224
|
+
test("vault#234: commented `# scope:` line is ignored, scope falls back to default", () => {
|
|
225
|
+
const fs = require("fs");
|
|
226
|
+
const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234", "vault.yaml");
|
|
227
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234"), { recursive: true });
|
|
228
|
+
// The only `scope:` line is commented out; the parser must NOT pick it up.
|
|
229
|
+
fs.writeFileSync(
|
|
230
|
+
path,
|
|
231
|
+
`name: mv234\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_cmt\n label: commented\n # scope: read\n key_hash: sha256:cmt\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
232
|
+
);
|
|
233
|
+
const loaded = readVaultConfig("mv234");
|
|
234
|
+
const key = loaded!.api_keys.find((k) => k.id === "k_cmt");
|
|
235
|
+
expect(key).toBeDefined();
|
|
236
|
+
// Commented scope ignored → default "write", NOT "read".
|
|
237
|
+
expect(key!.scope).toBe("write");
|
|
238
|
+
expect(key!.key_hash).toBe("sha256:cmt");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("vault#234: value-less `scope: ` (trailing space) does NOT capture the next field", () => {
|
|
242
|
+
const fs = require("fs");
|
|
243
|
+
const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b", "vault.yaml");
|
|
244
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b"), { recursive: true });
|
|
245
|
+
// `scope: ` has a trailing space and no value; the OLD regex skipped the
|
|
246
|
+
// newline and captured `sha256:trailing` (the key_hash) as the scope.
|
|
247
|
+
fs.writeFileSync(
|
|
248
|
+
path,
|
|
249
|
+
`name: mv234b\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_trail\n label: trailing\n scope: \n key_hash: sha256:trailing\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
250
|
+
);
|
|
251
|
+
const loaded = readVaultConfig("mv234b");
|
|
252
|
+
const key = loaded!.api_keys.find((k) => k.id === "k_trail");
|
|
253
|
+
expect(key).toBeDefined();
|
|
254
|
+
// The hash must NOT have been borrowed as the scope.
|
|
255
|
+
expect(key!.scope).not.toBe("sha256:trailing");
|
|
256
|
+
expect(key!.scope).toBe("write"); // default
|
|
257
|
+
// And the real key_hash is still parsed correctly.
|
|
258
|
+
expect(key!.key_hash).toBe("sha256:trailing");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("vault#234: a valid `scope: read` still parses (positive control, both parsers)", () => {
|
|
262
|
+
const fs = require("fs");
|
|
263
|
+
// Vault-level parser.
|
|
264
|
+
const vpath = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c", "vault.yaml");
|
|
265
|
+
fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c"), { recursive: true });
|
|
266
|
+
fs.writeFileSync(
|
|
267
|
+
vpath,
|
|
268
|
+
`name: mv234c\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_v\n label: reader\n scope: read\n key_hash: sha256:v\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
269
|
+
);
|
|
270
|
+
expect(readVaultConfig("mv234c")!.api_keys.find((k) => k.id === "k_v")?.scope).toBe("read");
|
|
271
|
+
|
|
272
|
+
// Global parser, same grammar.
|
|
273
|
+
const gpath = join(process.env.PARACHUTE_HOME!, "vault", "config.yaml");
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
gpath,
|
|
276
|
+
`port: 1940\napi_keys:\n - id: k_g\n label: reader\n # scope: write\n scope: read\n key_hash: sha256:g\n created_at: "2026-01-01T00:00:00.000Z"\n`,
|
|
277
|
+
);
|
|
278
|
+
// The commented `# scope: write` is skipped; the real `scope: read` wins.
|
|
279
|
+
expect(readGlobalConfig().api_keys?.find((k) => k.id === "k_g")?.scope).toBe("read");
|
|
280
|
+
});
|
|
281
|
+
|
|
216
282
|
test("writeEnvFile writes .env at 0600 (SCRIBE_AUTH_TOKEN secrecy)", () => {
|
|
217
283
|
// Regression for vault#354 reviewer finding: the .env holds
|
|
218
284
|
// SCRIBE_AUTH_TOKEN (the vault↔scribe loopback bearer). On a
|
package/src/config.ts
CHANGED
|
@@ -518,14 +518,32 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
518
518
|
}
|
|
519
519
|
|
|
520
520
|
// Parse api_keys
|
|
521
|
+
//
|
|
522
|
+
// Accepted grammar (vault#234): each `id:` block is the writer's output —
|
|
523
|
+
// one field per line, indented two spaces under the `- id:` list item:
|
|
524
|
+
//
|
|
525
|
+
// - id: <id>
|
|
526
|
+
// label: <label> # free text to end of line
|
|
527
|
+
// scope: <scope> # single token (read|write|admin|…)
|
|
528
|
+
// key_hash: <hash> # single token
|
|
529
|
+
// created_at: "<iso>" # quoted or bare, no embedded newline
|
|
530
|
+
// last_used_at: "<iso>"
|
|
531
|
+
//
|
|
532
|
+
// Each field regex is line-anchored (`^...`, `m` flag) so a COMMENTED line
|
|
533
|
+
// (`# scope: read`) never matches — the line must begin with optional
|
|
534
|
+
// leading whitespace then the bare key. The value matcher uses horizontal
|
|
535
|
+
// whitespace only (`[^\S\r\n]*`, never `\s*`) after the colon so a
|
|
536
|
+
// value-less field (`scope: ` with a trailing space) can't skip the newline
|
|
537
|
+
// and capture the NEXT field's value. A missing optional field falls back to
|
|
538
|
+
// its default rather than borrowing a neighbor's token.
|
|
521
539
|
const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
|
|
522
540
|
for (const block of keyBlocks) {
|
|
523
541
|
const idMatch = block.match(/^(\S+)/);
|
|
524
|
-
const labelMatch = block.match(
|
|
525
|
-
const scopeMatch = block.match(
|
|
526
|
-
const hashMatch = block.match(
|
|
527
|
-
const createdAtMatch = block.match(
|
|
528
|
-
const lastUsedMatch = block.match(
|
|
542
|
+
const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
|
|
543
|
+
const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
|
|
544
|
+
const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
|
|
545
|
+
const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
546
|
+
const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
529
547
|
|
|
530
548
|
if (idMatch && hashMatch) {
|
|
531
549
|
config.api_keys.push({
|
|
@@ -1250,16 +1268,19 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1250
1268
|
}
|
|
1251
1269
|
|
|
1252
1270
|
// Parse global api_keys
|
|
1271
|
+
// Same line-anchored grammar as the vault-level parser above (vault#234)
|
|
1272
|
+
// — commented lines don't match; a value-less field can't capture the
|
|
1273
|
+
// next field's token across the newline.
|
|
1253
1274
|
const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
|
|
1254
1275
|
if (keyBlocks.length > 0) {
|
|
1255
1276
|
config.api_keys = [];
|
|
1256
1277
|
for (const block of keyBlocks) {
|
|
1257
1278
|
const idMatch = block.match(/^(\S+)/);
|
|
1258
|
-
const labelMatch = block.match(
|
|
1259
|
-
const scopeMatch = block.match(
|
|
1260
|
-
const hashMatch = block.match(
|
|
1261
|
-
const createdAtMatch = block.match(
|
|
1262
|
-
const lastUsedMatch = block.match(
|
|
1279
|
+
const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
|
|
1280
|
+
const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
|
|
1281
|
+
const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
|
|
1282
|
+
const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
1283
|
+
const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
|
|
1263
1284
|
if (idMatch && hashMatch) {
|
|
1264
1285
|
config.api_keys.push({
|
|
1265
1286
|
id: idMatch[1]!,
|
package/src/mcp-tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
10
|
+
import { getNoteTags } from "../core/src/notes.ts";
|
|
10
11
|
import type { Note } from "../core/src/types.ts";
|
|
11
12
|
import {
|
|
12
13
|
buildVaultProjection,
|
|
@@ -135,9 +136,27 @@ export function generateScopedMcpTools(
|
|
|
135
136
|
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
136
137
|
: undefined;
|
|
137
138
|
|
|
139
|
+
// Tag-scope hop-guard for `near[]` (vault#439): a per-note predicate the
|
|
140
|
+
// core BFS consults so it refuses to traverse THROUGH out-of-scope notes —
|
|
141
|
+
// symmetric with find-path. Reads from the SAME shared `allowedHolder` the
|
|
142
|
+
// result-filter populates; the query-notes wrapper `await getAllowed()`s
|
|
143
|
+
// (which fills the holder) before core's execute runs the BFS, so the
|
|
144
|
+
// allowlist is ready by the time this fires. Looks up each candidate note's
|
|
145
|
+
// tags by id (sync, core-native). Unscoped sessions install no predicate.
|
|
146
|
+
const nearTraversable = scoped
|
|
147
|
+
? (noteId: string) =>
|
|
148
|
+
noteWithinTagScope(
|
|
149
|
+
{ id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
|
|
150
|
+
allowedHolder.value,
|
|
151
|
+
rawTags,
|
|
152
|
+
)
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
138
155
|
const tools = generateMcpTools(
|
|
139
156
|
store,
|
|
140
|
-
expandVisibility
|
|
157
|
+
expandVisibility || nearTraversable
|
|
158
|
+
? { ...(expandVisibility ? { expandVisibility } : {}), ...(nearTraversable ? { nearTraversable } : {}) }
|
|
159
|
+
: undefined,
|
|
141
160
|
);
|
|
142
161
|
|
|
143
162
|
overrideVaultInfo(tools, vaultName, auth);
|