@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.
- package/core/src/core.test.ts +54 -0
- package/core/src/links.ts +19 -1
- package/core/src/mcp.ts +21 -9
- 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 +19 -11
- package/src/routing.test.ts +192 -1
- package/src/routing.ts +32 -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 ----
|
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[]` (
|
|
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
|
}
|
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);
|
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[]` (
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
const
|
|
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
|
}
|
package/src/routing.test.ts
CHANGED
|
@@ -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 {
|