@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5

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/src/mcp-tools.ts CHANGED
@@ -22,8 +22,15 @@ import {
22
22
  expandTokenTagScope,
23
23
  filterHydratedLinksByTagScope,
24
24
  noteWithinTagScope,
25
+ scrubIndexedFieldConflictError,
26
+ scrubParentCycleError,
27
+ scrubReferencingTagsByScope,
28
+ scrubTagFieldViolationsByScope,
25
29
  tagsWithinScope,
26
30
  } from "./tag-scope.ts";
31
+ import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
32
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
33
+ import { runDoctorScan } from "../core/src/doctor.ts";
27
34
  import {
28
35
  findTokensReferencingTag,
29
36
  recordMcpMintLedger,
@@ -215,11 +222,12 @@ export function generateScopedMcpTools(
215
222
  }
216
223
 
217
224
  /**
218
- * Tag-delete and (future) tag-merge always check for tag-scoped tokens
219
- * referencing the doomed tag — regardless of whether the *deleter* is
220
- * itself tag-scoped. A successful delete that orphans an allowlist would
225
+ * Tag-delete and tag-merge always check for tag-scoped tokens referencing
226
+ * the doomed tag(s) — regardless of whether the CALLER is itself
227
+ * tag-scoped. A successful delete/merge that orphans an allowlist would
221
228
  * silently widen surface area downstream. Mirrors the REST 409
222
- * `tag_in_use_by_tokens` envelope.
229
+ * `tag_in_use_by_tokens` envelope (routes.ts's DELETE /tags/:name and
230
+ * POST /tags/merge run the identical check).
223
231
  */
224
232
  function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
225
233
  const store = getVaultStore(vaultName);
@@ -239,6 +247,29 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
239
247
  }
240
248
  return await orig(params);
241
249
  });
250
+ // vault#552: merging consumes every source tag (same identity-drop as
251
+ // delete), so a source referenced by a tag-scoped token would orphan that
252
+ // token's allowlist exactly like a bare delete would. Aggregate matches
253
+ // across all sources into one 409-equivalent envelope, same shape REST's
254
+ // POST /tags/merge returns.
255
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
256
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
257
+ const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
258
+ for (const src of sources) {
259
+ if (typeof src !== "string") continue;
260
+ const tokens = findTokensReferencingTag(store.db, src);
261
+ if (tokens.length > 0) referenced.push({ source: src, tokens });
262
+ }
263
+ if (referenced.length > 0) {
264
+ return {
265
+ error: "TagInUseByTokens",
266
+ error_type: "tag_in_use_by_tokens",
267
+ message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
268
+ referenced_by: referenced,
269
+ };
270
+ }
271
+ return await orig(params);
272
+ });
242
273
  }
243
274
 
244
275
  /**
@@ -344,7 +375,7 @@ function applyTagScopeWrappers(
344
375
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
345
376
  return noteWithinTagScope(result as any, allowed, rawTags)
346
377
  ? scrubNoteLinks(result)
347
- : { error: "Note not found", id: (result as any).id };
378
+ : { error: "Note not found", error_type: "not_found", id: (result as any).id };
348
379
  }
349
380
  return result;
350
381
  });
@@ -470,7 +501,7 @@ function applyTagScopeWrappers(
470
501
  if (!id) continue;
471
502
  const existing = await store.getNote(id as string);
472
503
  if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
473
- return { error: "Note not found", id };
504
+ return { error: "Note not found", error_type: "not_found", id };
474
505
  }
475
506
  const removed = new Set<string>((item as any).tags?.remove ?? []);
476
507
  const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
@@ -489,7 +520,7 @@ function applyTagScopeWrappers(
489
520
  if (id) {
490
521
  const existing = await store.getNote(id as string);
491
522
  if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
492
- return { error: "Note not found", id };
523
+ return { error: "Note not found", error_type: "not_found", id };
493
524
  }
494
525
  }
495
526
  return await orig(params);
@@ -502,7 +533,45 @@ function applyTagScopeWrappers(
502
533
  if (typeof tag === "string" && !allowed.has(tag)) {
503
534
  return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
504
535
  }
505
- return await orig(params);
536
+ try {
537
+ return await orig(params);
538
+ } catch (err: any) {
539
+ // Tag-scope scrub for cross-tag field conflicts (vault#554
540
+ // auth-and-scope fold). Core's validation scans EVERY tag's schema
541
+ // (scope-unaware by architecture), so its TagFieldConflictError can
542
+ // name an OUT-OF-SCOPE tag and reveal its declared type/flag in both
543
+ // the violation entries and the error message. The write stays
544
+ // rejected — schema integrity is scope-independent — but re-throw
545
+ // with scrubbed violations (out-of-scope declarers generalized, no
546
+ // tag name / declared type, `other_tag` dropped; in-scope declarers
547
+ // keep full detail). Rebuilding via the constructor also rebuilds
548
+ // the top-level message from the scrubbed entries. Same pattern as
549
+ // the list-tags `did_you_mean` scrub above.
550
+ if (err && err.code === "TAG_FIELD_CONFLICT" && Array.isArray(err.violations)) {
551
+ throw new TagFieldConflictError(
552
+ err.tag ?? (tag as string),
553
+ scrubTagFieldViolationsByScope(err.violations, allowed),
554
+ );
555
+ }
556
+ // Same leak through the OTHER door (wire-review interaction): a
557
+ // both-indexed cross-tag type conflict deliberately bypasses the
558
+ // pre-check (preserving its declareField → invalid_indexed_field
559
+ // contract), and declareField's IndexedFieldError message names the
560
+ // other declarer tag(s). Generalize for scoped callers; solo
561
+ // own-field IndexedFieldErrors (no declarer_tags) pass untouched.
562
+ if (err instanceof IndexedFieldError) {
563
+ throw scrubIndexedFieldConflictError(err, allowed);
564
+ }
565
+ // parent_names cycle guard (vault#552) — the hierarchy `upsertTagRecord`
566
+ // validates against is vault-wide (scope-unaware by architecture), so
567
+ // the cycle path can name an out-of-scope tag. Same "write stays
568
+ // rejected, path gets generalized" posture as the field-conflict scrub
569
+ // above.
570
+ if (err instanceof ParentCycleError) {
571
+ throw scrubParentCycleError(err, allowed);
572
+ }
573
+ throw err;
574
+ }
506
575
  });
507
576
 
508
577
  wrapReadTool(tools, "delete-tag", async (orig, params) => {
@@ -512,9 +581,59 @@ function applyTagScopeWrappers(
512
581
  if (typeof tag === "string" && !allowed.has(tag)) {
513
582
  return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
514
583
  }
584
+ const result = await orig(params);
585
+ // Referential-integrity refusal (vault#552) — scrub out-of-scope
586
+ // referencing tag names before returning. The delete stays refused
587
+ // either way; only the response's tag-name visibility changes.
588
+ if (result && typeof result === "object" && (result as any).error_type === "tag_referenced_as_parent") {
589
+ return {
590
+ ...(result as Record<string, unknown>),
591
+ referencing_tags: scrubReferencingTagsByScope((result as any).referencing_tags ?? [], allowed),
592
+ };
593
+ }
594
+ return result;
595
+ });
596
+
597
+ // rename-tag / merge-tags (vault#552 — MCP parity with the pre-existing
598
+ // REST engine). Same tag-scope posture as update-tag/delete-tag: every
599
+ // tag NAMED in the request (source(s) + target, or old/new name) must be
600
+ // inside the caller's allowlist — a rename/merge that pulls a tag out of
601
+ // scope (or in) is a privilege-boundary move, refuse the whole op before
602
+ // it reaches core.
603
+ wrapReadTool(tools, "rename-tag", async (orig, params) => {
604
+ const allowed = await getAllowed();
605
+ if (!allowed) return await orig(params);
606
+ const oldName = (params as any).old_name ?? (params as any).from ?? (params as any).tag;
607
+ const newName = (params as any).new_name ?? (params as any).to;
608
+ for (const t of [oldName, newName]) {
609
+ if (typeof t === "string" && !allowed.has(t)) {
610
+ return forbidden(`rename-tag: tag "${t}" is outside the token's allowlist`);
611
+ }
612
+ }
613
+ return await orig(params);
614
+ });
615
+
616
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
617
+ const allowed = await getAllowed();
618
+ if (!allowed) return await orig(params);
619
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
620
+ const target = (params as any).target;
621
+ for (const t of [...sources, target]) {
622
+ if (typeof t === "string" && !allowed.has(t)) {
623
+ return forbidden(`merge-tags: tag "${t}" is outside the token's allowlist`);
624
+ }
625
+ }
515
626
  return await orig(params);
516
627
  });
517
628
 
629
+ // doctor (vault#552) — the scan itself is re-run with the caller's
630
+ // expanded allowlist rather than post-filtering the unscoped result, so
631
+ // aggregate counts (findings summary) never reflect out-of-scope data.
632
+ wrapReadTool(tools, "doctor", async (orig, params) => {
633
+ const allowed = await getAllowed();
634
+ if (!allowed) return await orig(params);
635
+ return runDoctorScan(store.db, { allowedTags: allowed });
636
+ });
518
637
  }
519
638
 
520
639
  function wrapReadTool(
@@ -542,6 +661,16 @@ function overrideVaultInfo(
542
661
  if (!vaultInfo) return;
543
662
 
544
663
  vaultInfo.execute = async (params) => {
664
+ // NOTE (vault#554): these two throws are deliberately left as plain,
665
+ // unstructured `Error`s — NOT given `error_type` — even though the rest
666
+ // of this file's sweep attaches one everywhere else. Attaching one here
667
+ // routes the throw through mcp-http.ts's structured-error mapping, which
668
+ // surfaces it as a JSON-RPC protocol-level error (`response.error`)
669
+ // instead of the in-band tool result the existing contract test asserts
670
+ // (`response.result.isError === true`, `response.result.content[0].text`
671
+ // containing the scope name) — see "tools/call of vault-info with
672
+ // description arg and vault:read scope is refused" in src/vault.test.ts.
673
+ // Changing that transport shape is out of scope for this wave.
545
674
  const config = readVaultConfig(vaultName);
546
675
  if (!config) throw new Error(`Vault "${vaultName}" not found`);
547
676
 
@@ -518,6 +518,11 @@ function importResultFromStats(
518
518
  `Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
519
519
  );
520
520
  }
521
+ for (const sp of stats.skipped_schema_parents) {
522
+ warnings.push(
523
+ `Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
524
+ );
525
+ }
521
526
  const result: ImportResult = {
522
527
  notes_imported: stats.notes_created + stats.notes_updated,
523
528
  tags_imported: stats.schemas_restored,