@openparachute/vault 0.5.1 → 0.5.2-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.
@@ -13,6 +13,7 @@ const baseInput = {
13
13
  bindHost: "127.0.0.1",
14
14
  port: 1940,
15
15
  mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
16
+ vaultName: "default",
16
17
  };
17
18
 
18
19
  function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
@@ -96,16 +97,70 @@ describe("buildInitSummaryLines", () => {
96
97
  });
97
98
  });
98
99
 
99
- describe("MCP=N + token=N (unreachable)", () => {
100
+ // vault#442: the DEFAULT init path — MCP wired, NO token minted (per-user
101
+ // OAuth). The summary must LEAD with the OAuth connect path, never mint, and
102
+ // never surface the old "no token issued" failure copy.
103
+ describe("MCP=Y + no token (vault#442 OAuth default)", () => {
104
+ const out = lines(true, false, undefined).join("\n");
105
+
106
+ test("leads with the OAuth connect message — no token needed", () => {
107
+ expect(out).toContain("no token needed, you'll sign in on first use");
108
+ });
109
+
110
+ test("tells the user Claude Code is already wired in", () => {
111
+ expect(out).toContain("Claude Code is already wired in");
112
+ });
113
+
114
+ test("shows the OAuth `claude mcp add` command for other clients", () => {
115
+ expect(out).toContain(
116
+ "claude mcp add --transport http parachute-vault http://127.0.0.1:1940/vault/default/mcp",
117
+ );
118
+ });
119
+
120
+ test("offers the scope-narrow opt-in mint for scripts (full vault:<name>:read, never admin)", () => {
121
+ // Must be the three-segment named-resource form the hub mint-token model
122
+ // requires — a bare `vault:read` would mint a malformed scope (vault#443).
123
+ expect(out).toContain("parachute auth mint-token --scope vault:default:read");
124
+ expect(out).not.toContain("--scope vault:read ");
125
+ expect(out).not.toMatch(/--scope vault:read$/m);
126
+ expect(out).not.toContain("vault:admin");
127
+ });
128
+
129
+ test("does NOT print or imply any minted token", () => {
130
+ expect(out).not.toContain("Your API token:");
131
+ expect(out).not.toContain("Baked into ~/.claude.json");
132
+ expect(out).not.toContain("Authorization: Bearer");
133
+ });
134
+
135
+ test("does NOT surface the old no-token-issued failure copy", () => {
136
+ expect(out).not.toContain("No token issued");
137
+ });
138
+
139
+ test("threads a non-default vault name into the mint-token scope", () => {
140
+ const out2 = buildInitSummaryLines({
141
+ ...baseInput,
142
+ vaultName: "journal",
143
+ mcpUrl: "http://127.0.0.1:1940/vault/journal/mcp",
144
+ addMcp: true,
145
+ addToken: false,
146
+ apiKey: undefined,
147
+ }).join("\n");
148
+ expect(out2).toContain("parachute auth mint-token --scope vault:journal:read");
149
+ expect(out2).not.toContain("vault:default:read");
150
+ });
151
+ });
152
+
153
+ describe("MCP=N + token=N (OAuth default, Claude Code not wired)", () => {
100
154
  const out = lines(false, false, undefined).join("\n");
101
155
 
102
- test("warns the vault is unreachable", () => {
103
- expect(out).toContain("your vault isn't reachable by any client");
156
+ test("frames skipping the MCP entry as OAuth-first, not 'unreachable'", () => {
157
+ expect(out).toContain("uses per-user OAuth, no token needed");
158
+ expect(out).not.toContain("your vault isn't reachable by any client");
104
159
  });
105
160
 
106
- test("points to the mcp-install recovery path (hub JWT)", () => {
161
+ test("points to mcp-install (no token-minting framing)", () => {
107
162
  expect(out).toContain("parachute-vault mcp-install");
108
- expect(out).toContain("mints a hub JWT");
163
+ expect(out).not.toContain("mints a hub JWT");
109
164
  });
110
165
 
111
166
  test("does not print any token", () => {
@@ -118,6 +173,23 @@ describe("buildInitSummaryLines", () => {
118
173
  });
119
174
  });
120
175
 
176
+ // Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
177
+ // reached only when the operator passes --token without a hub).
178
+ describe("MCP=N + token=Y but no hub (opt-in mint failed)", () => {
179
+ const out = buildInitSummaryLines({
180
+ ...baseInput,
181
+ addMcp: false,
182
+ addToken: true,
183
+ apiKey: undefined,
184
+ noTokenGuidance: "No token issued — hub unreachable.",
185
+ }).join("\n");
186
+
187
+ test("surfaces the no-token-issued guidance + recovery", () => {
188
+ expect(out).toContain("No token issued");
189
+ expect(out).toContain("parachute-vault mcp-install");
190
+ });
191
+ });
192
+
121
193
  test("always prints Config: and Server: lines", () => {
122
194
  for (const [addMcp, addToken] of [
123
195
  [true, true],
@@ -12,6 +12,13 @@ export type InitSummaryInput = {
12
12
  bindHost: string;
13
13
  port: number;
14
14
  mcpUrl: string;
15
+ /**
16
+ * The default vault's name — used to emit the three-segment
17
+ * `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
18
+ * (the hub mint-token model requires the named-resource form;
19
+ * a bare `vault:read` would mint a malformed scope). vault#442/#443.
20
+ */
21
+ vaultName: string;
15
22
  /**
16
23
  * Guidance from the bootstrap-credential step when no token could be issued
17
24
  * (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
@@ -23,44 +30,54 @@ export type InitSummaryInput = {
23
30
 
24
31
  /**
25
32
  * Build the post-install summary lines for `vault init`, branched on the
26
- * (addMcp, addToken, apiKey) decision matrix. Post-0.5.0 the token is a
27
- * hub-issued JWT minted via operator.token; when no hub is reachable `apiKey`
28
- * is undefined even though the operator opted in (`addToken`/`addMcp`):
33
+ * (addMcp, addToken, apiKey) decision matrix.
34
+ *
35
+ * vault#442: the DEFAULT is per-user OAuth no token is minted, and the
36
+ * Claude Code MCP entry is written without a baked bearer (browser sign-in on
37
+ * first connect). A token is minted only on explicit opt-in (`addToken`), and
38
+ * then scope-narrow. Branches:
29
39
  *
30
- * addMcp, addToken, apiKey token baked into claude.json + printed
31
- * addMcp, !addToken, apiKey → token baked into claude.json, hint
32
- * !addMcp, addToken, apiKey → token printed prominently
33
- * wanted-token-but-no-hub guidance: no token issued, recovery paths
34
- * !addMcp, !addToken warning: vault unreachable; recovery paths
40
+ * addMcp, !apiKey OAuth-first: connect, sign in on first use
41
+ * addMcp, addToken, apiKey → token baked into claude.json + printed
42
+ * addMcp, !addToken, apiKey → token baked into claude.json, hint
43
+ * !addMcp, addToken, apiKey → token printed prominently
44
+ * !addMcp, addToken, !apiKey opted into a token but no hub reachable
45
+ * !addMcp, !addToken → OAuth-first: add Claude Code later
35
46
  */
36
47
  export function buildInitSummaryLines(input: InitSummaryInput): string[] {
37
- const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
48
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance } = input;
38
49
  const lines: string[] = [];
39
50
  lines.push("");
40
51
  lines.push("---");
41
52
 
42
- const wantedToken = addMcp || addToken;
43
-
44
- if (addMcp && addToken && apiKey) {
53
+ if (addMcp && apiKey && addToken) {
45
54
  lines.push("");
46
55
  lines.push(`Your API token: ${apiKey}`);
47
56
  lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
48
57
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
49
58
  lines.push(` - Won't be shown again — save it now.`);
50
- } else if (addMcp && !addToken && apiKey) {
59
+ } else if (addMcp && apiKey && !addToken) {
51
60
  lines.push("");
52
61
  lines.push(
53
62
  "Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
54
63
  );
64
+ } else if (addMcp && !apiKey) {
65
+ // vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
66
+ // Claude Code signs in via browser OAuth on first connect. No token needed.
67
+ lines.push("");
68
+ lines.push("Connect your AI — no token needed, you'll sign in on first use:");
69
+ lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
70
+ lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
71
+ lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
55
72
  } else if (!addMcp && addToken && apiKey) {
56
73
  lines.push("");
57
74
  lines.push(`Your API token: ${apiKey}`);
58
75
  lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
59
76
  lines.push(` - Won't be shown again — save it now.`);
60
- } else if (wantedToken && !apiKey) {
61
- // Opted into a token but no hub was reachable to mint one (vault#282
62
- // Stage 2 — vault no longer mints local pvt_* tokens). Surface why and
63
- // the recovery paths.
77
+ } else if (!addMcp && addToken && !apiKey) {
78
+ // Explicitly opted into a token but no hub was reachable to mint one
79
+ // (vault#282 Stage 2 — vault no longer mints local pvt_* tokens). Surface
80
+ // why and the recovery paths.
64
81
  lines.push("");
65
82
  lines.push(
66
83
  noTokenGuidance ??
@@ -73,12 +90,13 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
73
90
  " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
74
91
  );
75
92
  } else if (!addMcp && !addToken) {
93
+ // OAuth-first, but the operator skipped wiring Claude Code too.
76
94
  lines.push("");
77
95
  lines.push(
78
- "You've skipped both MCP install and token generationyour vault isn't reachable by any client.",
96
+ "Skipped the Claude Code MCP entry. Add it anytimeit uses per-user OAuth, no token needed:",
79
97
  );
80
98
  lines.push(
81
- " Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
99
+ " parachute-vault mcp-install",
82
100
  );
83
101
  }
84
102
 
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 type { Note } from "../core/src/types.ts";
10
11
  import {
11
12
  buildVaultProjection,
12
13
  projectionToMarkdown,
@@ -18,6 +19,7 @@ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts
18
19
  import type { AuthResult } from "./auth.ts";
19
20
  import {
20
21
  expandTokenTagScope,
22
+ filterHydratedLinksByTagScope,
21
23
  noteWithinTagScope,
22
24
  tagsWithinScope,
23
25
  } from "./tag-scope.ts";
@@ -117,11 +119,30 @@ export function generateScopedMcpTools(
117
119
  callerBearer?: string | null,
118
120
  ): McpToolDef[] {
119
121
  const store = getVaultStore(vaultName);
120
- const tools = generateMcpTools(store);
122
+
123
+ // Tag-scope confidentiality (security review): when the session is
124
+ // tag-scoped, build an expand-visibility predicate so `query-notes`'s
125
+ // `expand_links` inlining can't embed out-of-scope note content. The
126
+ // predicate reads from a SHARED holder that `applyTagScopeWrappers`
127
+ // populates with the resolved allowlist before core's execute runs the
128
+ // (synchronous) expansion — so by the time core calls `isVisible(note)`
129
+ // the allowlist is ready. Core stays scope-unaware: it only receives the
130
+ // plain closure. Unscoped sessions pass no predicate (unchanged path).
131
+ const scoped = Boolean(auth?.scoped_tags && auth.scoped_tags.length > 0);
132
+ const allowedHolder: { value: Set<string> | null } = { value: null };
133
+ const rawTags = scoped ? auth!.scoped_tags : null;
134
+ const expandVisibility = scoped
135
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
136
+ : undefined;
137
+
138
+ const tools = generateMcpTools(
139
+ store,
140
+ expandVisibility ? { expandVisibility } : undefined,
141
+ );
121
142
 
122
143
  overrideVaultInfo(tools, vaultName, auth);
123
144
  applyTagDependencyGuards(tools, vaultName);
124
- applyTagScopeWrappers(tools, vaultName, auth);
145
+ applyTagScopeWrappers(tools, vaultName, auth, allowedHolder);
125
146
 
126
147
  // manage-token is server-only (needs token-store + auth context), so it
127
148
  // lives here rather than in core. Always appended to the surface; the
@@ -181,6 +202,7 @@ function applyTagScopeWrappers(
181
202
  tools: McpToolDef[],
182
203
  vaultName: string,
183
204
  auth: AuthResult | undefined,
205
+ allowedHolder?: { value: Set<string> | null },
184
206
  ): void {
185
207
  if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
186
208
  const store = getVaultStore(vaultName);
@@ -188,12 +210,40 @@ function applyTagScopeWrappers(
188
210
  let allowedPromise: Promise<Set<string> | null> | null = null;
189
211
  const getAllowed = (): Promise<Set<string> | null> => {
190
212
  if (!allowedPromise) {
191
- allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
213
+ allowedPromise = expandTokenTagScope(store, auth.scoped_tags).then((a) => {
214
+ // Publish the resolved allowlist into the shared holder so the
215
+ // expand-visibility predicate (wired in generateScopedMcpTools and
216
+ // baked into the query-notes expand context) sees the same set.
217
+ // The query-notes wrapper awaits getAllowed() before calling the
218
+ // core execute that runs expansion, so the holder is populated in
219
+ // time. Security review: closes the expand_links content leak.
220
+ if (allowedHolder) allowedHolder.value = a;
221
+ return a;
222
+ });
192
223
  }
193
224
  return allowedPromise;
194
225
  };
195
226
  const rawTags = auth.scoped_tags;
196
227
 
228
+ // Scrub a returned note's hydrated `links` array (present when the caller
229
+ // set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
230
+ // don't leak — symmetric with the REST `include_links` fix. Mutates in
231
+ // place and returns the note for chaining. No-op when `links` is absent.
232
+ //
233
+ // Ordering invariant: reading `allowedHolder.value` here is safe ONLY
234
+ // because every wrapper that calls scrubNoteLinks first does
235
+ // `await getAllowed()` (which populates the holder) before `orig(params)`
236
+ // and before this scrub runs. So by the time we read `holder.value` it is
237
+ // the resolved allowlist, never the initial `null`. The `?? null` fallback
238
+ // is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
239
+ // keys off `rawTags` (non-null here) for the actual scope check.
240
+ const scrubNoteLinks = (n: any): any => {
241
+ if (n && Array.isArray(n.links)) {
242
+ n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
243
+ }
244
+ return n;
245
+ };
246
+
197
247
  wrapReadTool(tools, "query-notes", async (orig, params) => {
198
248
  const allowed = await getAllowed();
199
249
  const result = await orig(params);
@@ -203,7 +253,9 @@ function applyTagScopeWrappers(
203
253
  // - `{notes, next_cursor}` (cursor mode, vault#313)
204
254
  // - `{...note}` with `id`+`tags` (single-note by id)
205
255
  if (Array.isArray(result)) {
206
- return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
256
+ return result
257
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
258
+ .map(scrubNoteLinks);
207
259
  }
208
260
  if (
209
261
  result &&
@@ -214,13 +266,15 @@ function applyTagScopeWrappers(
214
266
  ) {
215
267
  const r = result as { notes: any[]; next_cursor: string | null };
216
268
  return {
217
- notes: r.notes.filter((n: any) => noteWithinTagScope(n, allowed, rawTags)),
269
+ notes: r.notes
270
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
271
+ .map(scrubNoteLinks),
218
272
  next_cursor: r.next_cursor,
219
273
  };
220
274
  }
221
275
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
222
276
  return noteWithinTagScope(result as any, allowed, rawTags)
223
- ? result
277
+ ? scrubNoteLinks(result)
224
278
  : { error: "Note not found", id: (result as any).id };
225
279
  }
226
280
  return result;