@openparachute/vault 0.5.2-rc.5 → 0.5.3-rc.1

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 ----
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
  /**
@@ -124,6 +133,7 @@ export interface GenerateMcpToolsOpts {
124
133
  export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
125
134
  const db: Database = (store as any).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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.2-rc.5",
3
+ "version": "0.5.3-rc.1",
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);
package/src/routes.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { getNote, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
16
+ import { getNote, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
17
  import { attachValidationStatus } from "../core/src/mcp.ts";
18
18
  import * as linkOps from "../core/src/links.ts";
19
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
@@ -817,16 +817,24 @@ async function handleNotesInner(
817
817
  }
818
818
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
819
819
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
820
- // Tag-scope policy for `near[]` (output-filter, not hop-guard): the
821
- // BFS walks the FULL graph from the anchor, including out-of-scope
822
- // intermediate hops, then the RESULT set is tag-scope-filtered below
823
- // (`filterNotesByTagScope`). No out-of-scope content or ids leak
824
- // out-of-scope notes never survive into the response. This is
825
- // ASYMMETRIC with `find-path`, which guards every hop (it returns the
826
- // path itself, so an out-of-scope intermediary would be a leak there).
827
- // The asymmetry is deliberate; tracked at vault#439 should we ever want
828
- // `near[]` to also constrain traversal hops.
829
- const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
820
+ // Tag-scope policy for `near[]` (vault#439 hop-guard, symmetric with
821
+ // find-path): for a tag-scoped token the BFS refuses to traverse
822
+ // THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
823
+ // scoped to ["work"] can't reach an in-scope note at depth 2 via a
824
+ // #personal intermediary at depth 1; that note is simply unreachable.
825
+ // The `filterNotesByTagScope` pass below still runs (defense in depth),
826
+ // but the wall makes it redundant for the `near[]` result set.
827
+ // Unscoped tokens (`tagScope.raw === null`) install no predicate the
828
+ // FULL graph is walked exactly as before, behavior unchanged.
829
+ const isTraversable = tagScope.raw
830
+ ? (id: string) =>
831
+ noteWithinTagScope(
832
+ { id, tags: getNoteTags(db, id) } as Note,
833
+ tagScope.allowed,
834
+ tagScope.raw,
835
+ )
836
+ : undefined;
837
+ const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
830
838
  const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
831
839
  results = results.filter((n) => nearScope.has(n.id));
832
840
  }
@@ -30,7 +30,7 @@ const testDir = join(
30
30
  process.env.PARACHUTE_HOME = testDir;
31
31
 
32
32
  // Dynamic import after env override so modules pick up the tmp dir.
33
- const { route } = await import("./routing.ts");
33
+ const { route, filterVaultListForBinding } = await import("./routing.ts");
34
34
  const {
35
35
  readGlobalConfig,
36
36
  writeGlobalConfig,
@@ -318,6 +318,56 @@ describe("GET /vaults/list (public discovery)", () => {
318
318
  });
319
319
  });
320
320
 
321
+ // ---------------------------------------------------------------------------
322
+ // GET /vaults — authenticated metadata listing, filtered by vault binding
323
+ // (vault#259). Operator / admin-channel callers (vault_name === null) see the
324
+ // full list; a vault-bound caller sees only its own vault.
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe("GET /vaults (binding filter — vault#259)", () => {
328
+ // Pure policy helper — drives the filtering decision independent of the
329
+ // auth path (no current credential yields a non-null vault_name HERE, since
330
+ // authenticateGlobalRequest 401s hub JWTs; the helper pins the correct
331
+ // shape for any future vault-bound credential on this surface).
332
+ test("filterVaultListForBinding: null binding (operator) keeps the full list", () => {
333
+ const names = ["work", "boulder", "default"];
334
+ expect(filterVaultListForBinding(names, null)).toEqual(names);
335
+ });
336
+
337
+ test("filterVaultListForBinding: a vault-bound caller sees only its own vault", () => {
338
+ const names = ["work", "boulder", "default"];
339
+ expect(filterVaultListForBinding(names, "work")).toEqual(["work"]);
340
+ // No cross-vault info-leak: boulder/default are not disclosed.
341
+ expect(filterVaultListForBinding(names, "work")).not.toContain("boulder");
342
+ expect(filterVaultListForBinding(names, "work")).not.toContain("default");
343
+ });
344
+
345
+ test("filterVaultListForBinding: binding to a vault absent from the list yields empty", () => {
346
+ expect(filterVaultListForBinding(["work", "default"], "ghost")).toEqual([]);
347
+ });
348
+
349
+ test("operator token (VAULT_AUTH_TOKEN) gets the UNFILTERED full listing", async () => {
350
+ createVault("work");
351
+ createVault("boulder");
352
+ createVault("default");
353
+ const prev = process.env.VAULT_AUTH_TOKEN;
354
+ process.env.VAULT_AUTH_TOKEN = "op-secret-token-259";
355
+ try {
356
+ const req = new Request("http://localhost:1940/vaults", {
357
+ headers: { authorization: "Bearer op-secret-token-259" },
358
+ });
359
+ const res = await route(req, "/vaults");
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as { vaults: { name: string }[] };
362
+ const names = body.vaults.map((v) => v.name);
363
+ expect(new Set(names)).toEqual(new Set(["work", "boulder", "default"]));
364
+ } finally {
365
+ if (prev === undefined) delete process.env.VAULT_AUTH_TOKEN;
366
+ else process.env.VAULT_AUTH_TOKEN = prev;
367
+ }
368
+ });
369
+ });
370
+
321
371
  // ---------------------------------------------------------------------------
322
372
  // /vault/<name>/admin/* — admin SPA static-file mount. Detailed tests live
323
373
  // in admin-spa.test.ts (with a tmp dist dir); these pin the dispatch — i.e.
@@ -1640,6 +1690,147 @@ describe("scope enforcement on /api/*", () => {
1640
1690
  expect(res.status).toBe(201);
1641
1691
  });
1642
1692
 
1693
+ // ----- vault#439: near[] BFS is a WALL, not a sieve --------------------
1694
+ // For a tag-scoped token the graph traversal must refuse to walk THROUGH
1695
+ // an out-of-scope note. So an in-scope note reachable ONLY via an
1696
+ // out-of-scope intermediary is unreachable — symmetric with find-path.
1697
+ // Topology: A(#work) --link--> P(#personal) --link--> B(#work).
1698
+ // A token scoped to ["work"] anchored at A, depth 2:
1699
+ // - sieve (old): B survives (reached via P, then output-filtered to keep B)
1700
+ // - wall (new): P is the wall; B is never reached.
1701
+
1702
+ test("vault#439: tag-scoped near[] cannot reach an in-scope note through an out-of-scope hop", async () => {
1703
+ createVault("journal");
1704
+ const store = getVaultStore("journal");
1705
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1706
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1707
+ const b = await store.createNote("far-work", { tags: ["work"] });
1708
+ await store.createLink(a.id, p.id, "relates");
1709
+ await store.createLink(p.id, b.id, "relates");
1710
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1711
+
1712
+ // `route`'s second arg is the pathname only; the query rides on req.url.
1713
+ const pathname = "/vault/journal/api/notes";
1714
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1715
+ const res = await route(authed(token, "GET", full), pathname);
1716
+ expect(res.status).toBe(200);
1717
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1718
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1719
+ const ids = list.map((n) => n.id);
1720
+ // B is in-scope (#work) but only reachable via the out-of-scope #personal
1721
+ // bridge — the wall makes it unreachable.
1722
+ expect(ids).not.toContain(b.id);
1723
+ // P itself never leaks (it's out of scope).
1724
+ expect(ids).not.toContain(p.id);
1725
+ });
1726
+
1727
+ test("vault#439: tag-scoped near[] still reaches in-scope notes via in-scope hops", async () => {
1728
+ createVault("journal");
1729
+ const store = getVaultStore("journal");
1730
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1731
+ const mid = await store.createNote("mid-work", { tags: ["work"] });
1732
+ const far = await store.createNote("far-work", { tags: ["work"] });
1733
+ await store.createLink(a.id, mid.id, "relates");
1734
+ await store.createLink(mid.id, far.id, "relates");
1735
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1736
+
1737
+ const pathname = "/vault/journal/api/notes";
1738
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1739
+ const res = await route(authed(token, "GET", full), pathname);
1740
+ expect(res.status).toBe(200);
1741
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1742
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1743
+ const ids = list.map((n) => n.id);
1744
+ // All-in-scope path: both mid (depth 1) and far (depth 2) are reachable.
1745
+ expect(ids).toContain(mid.id);
1746
+ expect(ids).toContain(far.id);
1747
+ });
1748
+
1749
+ test("vault#439: UNSCOPED token near[] still walks the full graph (behavior unchanged)", async () => {
1750
+ createVault("journal");
1751
+ const store = getVaultStore("journal");
1752
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1753
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1754
+ const b = await store.createNote("far-work", { tags: ["work"] });
1755
+ await store.createLink(a.id, p.id, "relates");
1756
+ await store.createLink(p.id, b.id, "relates");
1757
+ // No scopedTags → unscoped admin token; no wall installed.
1758
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
1759
+
1760
+ const pathname = "/vault/journal/api/notes";
1761
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1762
+ const res = await route(authed(token, "GET", full), pathname);
1763
+ expect(res.status).toBe(200);
1764
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1765
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1766
+ const ids = list.map((n) => n.id);
1767
+ // Unscoped: the full neighborhood is visible, including the #personal
1768
+ // bridge and the note beyond it.
1769
+ expect(ids).toContain(p.id);
1770
+ expect(ids).toContain(b.id);
1771
+ });
1772
+
1773
+ // ----- vault#404: REST-path hub-JWT tag-scoping enforcement (C0) --------
1774
+ // The MCP path's tag-scope enforcement is covered end-to-end; pin that a
1775
+ // tag-scoped HUB JWT (allowlist carried in the `permissions.scoped_tags`
1776
+ // claim, NOT a vestigial DB row) hitting the REST surface enforces the
1777
+ // same allowlist on both read and write. `mintTagScopedToken` mints a real
1778
+ // hub JWT, so these tests exercise the hub-JWT-sourced `scoped_tags` path.
1779
+
1780
+ test("vault#404: hub-JWT tag-scoped READ via REST enforces the allowlist (list + single)", async () => {
1781
+ createVault("journal");
1782
+ const store = getVaultStore("journal");
1783
+ const inScope = await store.createNote("h", { tags: ["health"] });
1784
+ const outOfScope = await store.createNote("w", { tags: ["work"] });
1785
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["health"]);
1786
+
1787
+ // List: only in-scope notes.
1788
+ const listPath = "/vault/journal/api/notes";
1789
+ const listRes = await route(authed(token, "GET", listPath), listPath);
1790
+ expect(listRes.status).toBe(200);
1791
+ const listBody = (await listRes.json()) as { notes?: { id: string }[] } | { id: string }[];
1792
+ const list = Array.isArray(listBody) ? listBody : (listBody.notes ?? []);
1793
+ const ids = list.map((n) => n.id);
1794
+ expect(ids).toContain(inScope.id);
1795
+ expect(ids).not.toContain(outOfScope.id);
1796
+
1797
+ // Single in-scope → 200; single out-of-scope → 404 (no existence leak).
1798
+ const okPath = `/vault/journal/api/notes/${inScope.id}`;
1799
+ expect((await route(authed(token, "GET", okPath), okPath)).status).toBe(200);
1800
+ const denyPath = `/vault/journal/api/notes/${outOfScope.id}`;
1801
+ expect((await route(authed(token, "GET", denyPath), denyPath)).status).toBe(404);
1802
+ });
1803
+
1804
+ test("vault#404: hub-JWT tag-scoped WRITE via REST enforces the allowlist", async () => {
1805
+ createVault("journal");
1806
+ const token = await mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
1807
+
1808
+ // In-scope write → 201.
1809
+ const path = "/vault/journal/api/notes";
1810
+ const okRes = await route(
1811
+ new Request(`http://localhost:1940${path}`, {
1812
+ method: "POST",
1813
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1814
+ body: JSON.stringify({ content: "ok", tags: ["health"] }),
1815
+ }),
1816
+ path,
1817
+ );
1818
+ expect(okRes.status).toBe(201);
1819
+
1820
+ // Out-of-scope write → 403 tag_scope_violation.
1821
+ const denyRes = await route(
1822
+ new Request(`http://localhost:1940${path}`, {
1823
+ method: "POST",
1824
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1825
+ body: JSON.stringify({ content: "denied", tags: ["work"] }),
1826
+ }),
1827
+ path,
1828
+ );
1829
+ expect(denyRes.status).toBe(403);
1830
+ const denyBody = (await denyRes.json()) as { error_type?: string };
1831
+ expect(denyBody.error_type).toBe("tag_scope_violation");
1832
+ });
1833
+
1643
1834
  // ----- Q5: tag-delete dependency check ---------------------------------
1644
1835
  // Deleting a tag referenced by any token's scoped_tags would silently
1645
1836
  // orphan the token's allowlist; fail closed with 409 + referenced_by.
package/src/routing.ts CHANGED
@@ -180,6 +180,23 @@ function handleParachuteIcon(): Response {
180
180
  });
181
181
  }
182
182
 
183
+ /**
184
+ * Filter a server-wide vault-name list for a caller's per-vault binding
185
+ * (vault#259). `vaultName === null` is the operator / admin-channel caller
186
+ * (server-wide VAULT_AUTH_TOKEN or legacy cross-vault config.yaml key) — they
187
+ * keep the FULL list. A non-null binding reduces the list to just that vault
188
+ * (empty if the bound vault isn't in the listing). Pure + exported so the
189
+ * policy is unit-testable independent of the auth path. See the `/vaults`
190
+ * handler for the full rationale.
191
+ */
192
+ export function filterVaultListForBinding(
193
+ names: string[],
194
+ vaultName: string | null,
195
+ ): string[] {
196
+ if (vaultName === null) return names;
197
+ return names.filter((name) => name === vaultName);
198
+ }
199
+
183
200
  export async function route(
184
201
  req: Request,
185
202
  path: string,
@@ -286,10 +303,24 @@ export async function route(
286
303
  }
287
304
 
288
305
  // Authenticated vault metadata list.
306
+ //
307
+ // Vault-binding filter (vault#259, info-leak follow-up): `/vaults` is a
308
+ // cross-vault DISCOVERY endpoint, not a single-vault operational one (unlike
309
+ // /mcp, which legitimately routes a vault-bound token back into its own
310
+ // vault). A caller bound to one vault shouldn't learn that OTHER vaults exist
311
+ // on this server. So when the authenticated caller carries a per-vault
312
+ // binding (`auth.vault_name !== null`), the listing is reduced to just that
313
+ // vault. Operator / admin-channel callers — the server-wide VAULT_AUTH_TOKEN
314
+ // and legacy cross-vault config.yaml keys — have `vault_name === null` and
315
+ // keep the full listing (they're explicitly the cross-vault management
316
+ // channel). `authenticateGlobalRequest` already 401s hub JWTs here, so the
317
+ // only callers that reach this point today are operator-channel
318
+ // (`vault_name === null`); this filter is the security-correct shape for any
319
+ // future vault-bound credential that becomes accepted on this surface.
289
320
  if (path === "/vaults" && req.method === "GET") {
290
321
  const auth = await authenticateGlobalRequest(req);
291
322
  if ("error" in auth) return auth.error;
292
- const names = listVaults();
323
+ const names = filterVaultListForBinding(listVaults(), auth.vault_name);
293
324
  const vaults = names.map((name) => {
294
325
  const config = readVaultConfig(name);
295
326
  return {