@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.
@@ -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 = (store as any).db;
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[]` (output-filter, not hop-guard): core
309
- // is scope-unaware, so this BFS walks the FULL graph from the anchor
310
- // including out-of-scope intermediate hops. For a tag-scoped session
311
- // the server's `applyTagScopeWrappers` (mcp-tools.ts) tag-filters the
312
- // RESULT list AFTER execute, so out-of-scope notes never survive into
313
- // the response no content/ids leak. This is ASYMMETRIC with
314
- // `find-path`, which guards every hop (it returns the path itself, so
315
- // an out-of-scope intermediary would be a leak there). The asymmetry is
316
- // deliberate; tracked at vault#439.
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
- // Re-read after schema-default population so the response reflects the
590
- // final on-disk state, then attach `validation_status` from any
591
- // tag's `fields` declaration that applies to this note.
592
- const final = created.map((n) => attachValidationStatus(store, db, n));
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
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.2",
3
+ "version": "0.5.3-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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(/label:\s*(.+)/);
525
- const scopeMatch = block.match(/scope:\s*(\S+)/);
526
- const hashMatch = block.match(/key_hash:\s*(\S+)/);
527
- const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
528
- const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
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(/label:\s*(.+)/);
1259
- const scopeMatch = block.match(/scope:\s*(\S+)/);
1260
- const hashMatch = block.match(/key_hash:\s*(\S+)/);
1261
- const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
1262
- const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
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 ? { expandVisibility } : undefined,
157
+ expandVisibility || nearTraversable
158
+ ? { ...(expandVisibility ? { expandVisibility } : {}), ...(nearTraversable ? { nearTraversable } : {}) }
159
+ : undefined,
141
160
  );
142
161
 
143
162
  overrideVaultInfo(tools, vaultName, auth);