@openparachute/vault 0.4.6-rc.3 → 0.4.6

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/README.md CHANGED
@@ -197,6 +197,9 @@ parachute-vault mcp-install --install-scope user # write ~/.claude.json top
197
197
  parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
198
198
  parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
199
199
  parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
200
+ parachute-vault mcp-install --dry-run # describe the write without touching disk or the hub
201
+ parachute-vault mcp-config gitcoin # emit JSON for `claude -p --mcp-config "$(...)"`
202
+ parachute-vault mcp-config gitcoin --env-vars # template form with ${PARACHUTE_HUB_URL}/${PARACHUTE_VAULT_TOKEN}
200
203
 
201
204
  # OAuth — owner password + 2FA
202
205
  parachute-vault set-password # set/change the owner password (OAuth consent page)
@@ -619,8 +622,37 @@ parachute-vault mcp-install --legacy-pat
619
622
 
620
623
  **Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
621
624
 
625
+ **Dry-run.** `parachute-vault mcp-install --dry-run` prints the write that would happen — target file, install scope, entry key, URL, auth mode — without touching disk or hitting the hub. Useful for probing the command's effect; in particular, `--help` no longer creates an empty `projects[<cwd>]` entry as a side effect, but `--dry-run` is the deliberate "tell me what you'd do" path.
626
+
622
627
  **Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
623
628
 
629
+ #### Headless flows — `claude -p` runners (`mcp-config`)
630
+
631
+ `mcp-install` writes a persistent entry into a Claude Code config file. That's the right shape for interactive sessions, but it has two sharp edges for headless runners that spawn `claude -p` against a vault:
632
+
633
+ 1. **Local-scope MCP entries don't propagate to `claude -p` subprocesses.** A project-scoped install under `projects[<cwd>].mcpServers` is visible to an *interactive* `claude` launched from that directory, but a `claude -p` invocation spawned by a Python script — even from the same directory, even with `--setting-sources user,project,local` — doesn't pick it up. Use `--install-scope user` for scripts, cron jobs, and runners; the local default is fine for interactive sessions.
634
+ 2. **`--mcp-config` JSON is per-runner boilerplate.** Some runners prefer to inline the MCP config rather than mutate the user's Claude Code state. `parachute-vault mcp-config <vault-name>` emits exactly the JSON shape `--mcp-config` consumes:
635
+
636
+ ```bash
637
+ # Mint or fetch a vault-scoped token first, then:
638
+ export PARACHUTE_VAULT_TOKEN=pvt_...
639
+ claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
640
+ --strict-mcp-config \
641
+ "Summarize the latest notes under projects/gitcoin"
642
+
643
+ # Or commit a template and expand at use-time:
644
+ parachute-vault mcp-config gitcoin --env-vars > .claude/mcp-gitcoin.json
645
+ # ...then later, when invoking claude:
646
+ export PARACHUTE_HUB_URL=http://127.0.0.1:1940
647
+ export PARACHUTE_VAULT_TOKEN=pvt_...
648
+ claude -p --mcp-config "$(envsubst < .claude/mcp-gitcoin.json)" \
649
+ --strict-mcp-config ...
650
+ ```
651
+
652
+ **Note on `--env-vars` expansion.** The file-save pattern requires the caller to expand `${...}` placeholders at use-time (e.g. via `envsubst`, available on most Linux distros and via `brew install gettext` on macOS). Bare `$(cat .claude/mcp-gitcoin.json)` feeds literal `${PARACHUTE_HUB_URL}` strings into `claude -p` — the shell only expands placeholders inside the command-substitution *source*, not inside the *captured output*. `claude -p` itself doesn't expand `${...}` either, so the substitution has to happen between the file and the command.
653
+
654
+ Flags: `--token <bearer>` (alternative to `PARACHUTE_VAULT_TOKEN`); `--base-url <url>` (override the auto-detected origin, useful for tailnet-exposed hubs: `--base-url https://hub.tail.ts.net`); `--env-vars` (emit the template form with `${PARACHUTE_HUB_URL}` and `${PARACHUTE_VAULT_TOKEN}` placeholders, safe to commit). With no token and no `--env-vars`, the command exits 1 with a clear error — runners get a fail-fast.
655
+
624
656
  ## Data model
625
657
 
626
658
  ```
@@ -729,6 +761,15 @@ The checks, in the order they're emitted:
729
761
  - **Claude Code shows no vault tools.** Check in order: (1) is the daemon up (`parachute-vault status`)? (2) does `~/.claude.json` have a `parachute-vault` entry with both `url` and a valid `Authorization` header? (3) does the URL's vault name match an existing vault? `parachute-vault doctor` catches the first two. A missing or stale `Authorization` header after a bare `vault mcp-install` is the usual culprit for #2 — see the Claude Code section of [Connecting a client](#connecting-a-client) for how to rewrite it.
730
762
  - **Claude Desktop / Daily won't connect via OAuth.** If the owner-password prompt was skipped at `vault init`, the consent page falls back to requiring a vault token in place of the password (functional but clunky). Set one now with `parachute-vault set-password`. If 2FA is enrolled, have your authenticator app ready before starting the flow; lost TOTP access recovers via the backup codes printed at enrollment.
731
763
  - **Scheduled backups aren't running.** On macOS: `doctor` flags `backup agent: not loaded` when `schedule` isn't `manual` but the launchd agent is missing — rerun `parachute-vault backup --schedule <freq>` to reinstall it. On Linux: systemd-timer support for backup isn't shipped yet, so `--schedule daily` silently skips the scheduler. Run `parachute-vault backup` from cron (or similar) until that lands.
764
+ - **Manual `curl` against `/vault/<name>/mcp` returns `406 Not Acceptable`.** The MCP HTTP transport requires both `application/json` and `text/event-stream` in the `Accept` header (it negotiates between the JSON response and the SSE streaming variant). Claude Code's `--mcp-config` http transport sets this automatically — the symptom only shows up when you probe the endpoint by hand. The fix:
765
+
766
+ ```bash
767
+ curl -H 'Accept: application/json, text/event-stream' \
768
+ -H "Authorization: Bearer $VAULT_TOKEN" \
769
+ http://127.0.0.1:1940/vault/default/mcp
770
+ ```
771
+
772
+ Both media types are needed; omitting either is what triggers the 406.
732
773
 
733
774
  ### Getting help
734
775
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.6-rc.3",
3
+ "version": "0.4.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
- "@openparachute/scope-guard": "^0.2.0",
26
+ "@openparachute/scope-guard": "^0.3.0",
27
27
  "jose": "^6.2.2",
28
28
  "otpauth": "^9.5.0",
29
29
  "qrcode-terminal": "^0.12.0"
@@ -98,12 +98,24 @@ interface SignOpts {
98
98
  ttlSeconds?: number;
99
99
  /** Override the random jti — needed when a test wants to revoke this exact token. */
100
100
  jti?: string;
101
+ /**
102
+ * `vault_scope` claim (multi-user Phase 1 PR 4 / scope-guard 0.3+).
103
+ * Undefined → omit the claim entirely (pre-PR-4 token shape; surfaces
104
+ * at scope-guard as `[]` = unrestricted). Provide `[]` explicitly for
105
+ * a hub-minted admin token; provide `["<name>"]` for a non-admin user.
106
+ */
107
+ vaultScope?: string[];
101
108
  }
102
109
 
103
110
  async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
104
111
  const iat = Math.floor(Date.now() / 1000);
105
112
  const exp = iat + (opts.ttlSeconds ?? 60);
106
- return await new SignJWT({ scope: opts.scope, client_id: "test-client" })
113
+ const payload: Record<string, unknown> = {
114
+ scope: opts.scope,
115
+ client_id: "test-client",
116
+ };
117
+ if (opts.vaultScope !== undefined) payload.vault_scope = opts.vaultScope;
118
+ return await new SignJWT(payload)
107
119
  .setProtectedHeader({ alg: "RS256", kid: kp.kid })
108
120
  .setIssuer(opts.iss)
109
121
  .setSubject(opts.sub ?? "user-1")
@@ -319,6 +331,154 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
319
331
  }
320
332
  });
321
333
 
334
+ test("vault_scope=[aaron] reaching /vault/aaron → success (matching pin)", async () => {
335
+ // Non-admin user assigned to vault "aaron" presenting their token
336
+ // at their own vault. The vault_scope claim names "aaron"; the
337
+ // request targets "aaron"; the defense-in-depth check passes.
338
+ seedVault("aaron");
339
+ const token = await signJwt(kp, {
340
+ iss: fixture.origin,
341
+ aud: "vault.aaron",
342
+ scope: "vault:aaron:write",
343
+ vaultScope: ["aaron"],
344
+ });
345
+ const config = readVaultConfig("aaron")!;
346
+ const store = getVaultStore("aaron");
347
+
348
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
349
+ expect("error" in result).toBe(false);
350
+ if (!("error" in result)) {
351
+ expect(result.permission).toBe("full");
352
+ expect(result.scopes).toEqual(["vault:aaron:write"]);
353
+ }
354
+ });
355
+
356
+ test("vault_scope=[aaron] reaching /vault/bob (cross-vault) → 403 vault_scope_mismatch", async () => {
357
+ // The exact threat model multi-user Phase 1 PR 5 protects against.
358
+ // A non-admin user pinned to "aaron" somehow gets a token naming
359
+ // "bob" in its scope string (mint bug, edited token, third-party RS
360
+ // bug, replay attack). The audience strict-check inside
361
+ // validateHubJwt would catch the obvious shape (aud=vault.bob
362
+ // reaching journal endpoint), but the case below pre-fabricates a
363
+ // token whose aud + scope DO name bob — only the vault_scope claim
364
+ // pins the user to aaron. Without the new enforcement, the request
365
+ // would coast through.
366
+ seedVault("aaron");
367
+ seedVault("bob");
368
+ const token = await signJwt(kp, {
369
+ iss: fixture.origin,
370
+ aud: "vault.bob", // audience matches bob (so the aud check passes)
371
+ scope: "vault:bob:write", // scope strings name bob too
372
+ vaultScope: ["aaron"], // but the user is pinned to aaron
373
+ });
374
+ const bobConfig = readVaultConfig("bob")!;
375
+ const bobStore = getVaultStore("bob");
376
+
377
+ const result = await authenticateVaultRequest(bearer(token), bobConfig, bobStore.db);
378
+ expect("error" in result).toBe(true);
379
+ if ("error" in result) {
380
+ expect(result.error.status).toBe(403);
381
+ const body = (await result.error.json()) as {
382
+ error: string;
383
+ error_type: string;
384
+ message: string;
385
+ required_vault: string;
386
+ granted_vault_scope?: unknown;
387
+ };
388
+ expect(body.error).toBe("Forbidden");
389
+ expect(body.error_type).toBe("vault_scope_mismatch");
390
+ // Message names both the pinned vault (aaron) and the requested
391
+ // vault (bob) so operators reading 403 logs can correlate to
392
+ // user assignment without needing to decode the token.
393
+ expect(body.message).toContain("aaron");
394
+ expect(body.message).toContain("bob");
395
+ expect(body.required_vault).toBe("bob");
396
+ // Surface hygiene: the pinned vault is intentionally NOT echoed
397
+ // back in a dedicated body field. The message carries enough
398
+ // diagnostic for an operator reading logs; a dedicated field
399
+ // would only leak the pin to a passive observer (the attacker
400
+ // already has it via local decode, but pattern hygiene says
401
+ // don't volunteer it).
402
+ expect(body.granted_vault_scope).toBeUndefined();
403
+ }
404
+ });
405
+
406
+ test("vault_scope=[] (admin token) passes any-vault check", async () => {
407
+ // Admin tokens carry vault_scope: [] — the explicit "no per-user pin"
408
+ // sentinel. The defense-in-depth check skips, and the request is
409
+ // gated purely by audience + scope strings. Both name "work" here,
410
+ // so the request succeeds.
411
+ seedVault("work");
412
+ const token = await signJwt(kp, {
413
+ iss: fixture.origin,
414
+ aud: "vault.work",
415
+ scope: "vault:work:admin",
416
+ vaultScope: [],
417
+ });
418
+ const config = readVaultConfig("work")!;
419
+ const store = getVaultStore("work");
420
+
421
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
422
+ expect("error" in result).toBe(false);
423
+ if (!("error" in result)) {
424
+ expect(result.permission).toBe("full");
425
+ expect(result.scopes).toEqual(["vault:work:admin"]);
426
+ }
427
+ });
428
+
429
+ test("pre-PR-4 token (vault_scope claim absent) → treated as admin (back-compat)", async () => {
430
+ // Every operator token + CLI-mint that existed before hub PR 4
431
+ // merged lacks the vault_scope claim entirely. scope-guard surfaces
432
+ // the absent claim as `[]`, which the helper treats as
433
+ // "unrestricted" — so the request goes through as long as audience
434
+ // + scope strings are correct. Without this back-compat, the entire
435
+ // pre-PR-4 fleet would 403 the moment this code shipped, which is
436
+ // the wrong tradeoff for a defense-in-depth check.
437
+ seedVault("legacy");
438
+ const token = await signJwt(kp, {
439
+ iss: fixture.origin,
440
+ aud: "vault.legacy",
441
+ scope: "vault:legacy:write",
442
+ // vaultScope intentionally omitted — pre-PR-4 token shape
443
+ });
444
+ const config = readVaultConfig("legacy")!;
445
+ const store = getVaultStore("legacy");
446
+
447
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
448
+ expect("error" in result).toBe(false);
449
+ if (!("error" in result)) {
450
+ expect(result.permission).toBe("full");
451
+ expect(result.scopes).toEqual(["vault:legacy:write"]);
452
+ }
453
+ });
454
+
455
+ test("vault_scope check runs AFTER broad-scope check — broad scope still wins the failure mode", async () => {
456
+ // A token with a broad vault scope AND a vault_scope pin gets
457
+ // rejected for the broad scope (more diagnostic). Pinning the
458
+ // ordering matters because the 401 message tells the operator
459
+ // "your token shape is wrong" whereas 403 vault_scope_mismatch
460
+ // tells them "your user can't reach this vault" — those have
461
+ // different remediation paths.
462
+ seedVault("aaron");
463
+ const token = await signJwt(kp, {
464
+ iss: fixture.origin,
465
+ aud: "vault.aaron",
466
+ scope: "vault:write", // broad — should trigger 401 first
467
+ vaultScope: ["other-vault"], // would also fail vault_scope check
468
+ });
469
+ const config = readVaultConfig("aaron")!;
470
+ const store = getVaultStore("aaron");
471
+
472
+ const result = await authenticateVaultRequest(bearer(token), config, store.db);
473
+ expect("error" in result).toBe(true);
474
+ if ("error" in result) {
475
+ // 401, not 403 — broad-scope rejection takes precedence.
476
+ expect(result.error.status).toBe(401);
477
+ const body = (await result.error.json()) as { error: string; message: string };
478
+ expect(body.message).toContain("broad vault scope");
479
+ }
480
+ });
481
+
322
482
  test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
323
483
  seedVault("journal");
324
484
  // Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
package/src/auth.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  SCOPE_READ,
32
32
  SCOPE_WRITE,
33
33
  } from "./scopes.ts";
34
+ import { enforceVaultScope } from "@openparachute/scope-guard";
34
35
  import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
35
36
 
36
37
  /**
@@ -241,9 +242,15 @@ export async function authenticateVaultRequest(
241
242
  // JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
242
243
  // verification inside validateHubJwt; signature checked against hub's JWKS.
243
244
  // Audience strict-checked against `vault.<name>` so a token stamped for
244
- // one vault can't reach another.
245
+ // one vault can't reach another. `vault_scope` claim additionally checked
246
+ // against `vaultConfig.name` so a per-user vault pin refuses cross-vault
247
+ // access even if a scope-string somehow named the wrong vault (multi-user
248
+ // Phase 1 defense-in-depth — see hub#283 + scope-guard 0.3.0).
245
249
  if (looksLikeJwt(key)) {
246
- return await authenticateHubJwt(key, { expectedAudience: `vault.${vaultConfig.name}` });
250
+ return await authenticateHubJwt(key, {
251
+ expectedAudience: `vault.${vaultConfig.name}`,
252
+ vaultName: vaultConfig.name,
253
+ });
247
254
  }
248
255
 
249
256
  // Try vault's token DB first
@@ -319,10 +326,20 @@ export async function authenticateVaultRequest(
319
326
  * here — Phase B2 settled that hub tokens always name the resource. Per-
320
327
  * vault audience enforcement happens inside `validateHubJwt` via
321
328
  * `opts.expectedAudience`.
329
+ *
330
+ * Per-user vault-pin enforcement (multi-user Phase 1 PR 5): when
331
+ * `opts.vaultName` is set, the token's `vault_scope` claim is checked
332
+ * against it via scope-guard's `enforceVaultScope`. Non-admin tokens
333
+ * (`vault_scope: [<assigned_vault>]`) refuse cross-vault access with a
334
+ * 403 + `vault_scope_mismatch` error code. Admin tokens (`vault_scope:
335
+ * []`) and pre-PR-4 tokens (claim absent → surfaced as `[]`) pass
336
+ * unchanged. The 403 (not 401) signals "your credential is valid but
337
+ * doesn't grant access to this vault" — distinct from authentication
338
+ * failures upstream. See hub#283 + scope-guard 0.3.0 for the mint side.
322
339
  */
323
340
  async function authenticateHubJwt(
324
341
  token: string,
325
- opts: { expectedAudience: string | null },
342
+ opts: { expectedAudience: string | null; vaultName?: string },
326
343
  ): Promise<{ error: Response } | AuthResult> {
327
344
  try {
328
345
  const claims = await validateHubJwt(token, { expectedAudience: opts.expectedAudience });
@@ -338,6 +355,43 @@ async function authenticateHubJwt(
338
355
  ),
339
356
  };
340
357
  }
358
+ // Defense-in-depth vault-pin check (multi-user Phase 1 PR 5). Runs
359
+ // AFTER the broad-scope check — a malformed token gets the more-
360
+ // descriptive scope-shape error rather than a generic pin-mismatch.
361
+ // When `vaultName` is unset (no per-vault binding known at this
362
+ // call site), the check is skipped — the audience strict-check
363
+ // inside validateHubJwt is the primary pin in that case.
364
+ if (opts.vaultName !== undefined && !enforceVaultScope(claims, opts.vaultName)) {
365
+ // Invariant: by the contract of `enforceVaultScope`, the false
366
+ // branch requires `claims.vaultScope.length > 0` — an empty
367
+ // `vaultScope` always returns true. The defensive assertion
368
+ // pins that so a future refactor can't slip an empty-scope
369
+ // request into this branch (which would render an empty
370
+ // parenthesised list in the message and break the diagnostic).
371
+ // Body shape: client gets `required_vault` + a message naming
372
+ // the conflict. The token's pinned vault is intentionally NOT
373
+ // echoed back — the attacker holds the token (and can decode
374
+ // claims locally), so the message would only leak the pin to a
375
+ // legitimate operator looking at a 403 log line. They already
376
+ // know which user they assigned where; the required_vault is
377
+ // the actionable piece.
378
+ if (claims.vaultScope.length === 0) {
379
+ throw new Error(
380
+ "unreachable: enforceVaultScope returned false on an empty vaultScope",
381
+ );
382
+ }
383
+ return {
384
+ error: Response.json(
385
+ {
386
+ error: "Forbidden",
387
+ error_type: "vault_scope_mismatch",
388
+ message: `token's vault_scope (${claims.vaultScope.join(", ")}) does not include the requested vault '${opts.vaultName}'`,
389
+ required_vault: opts.vaultName,
390
+ },
391
+ { status: 403 },
392
+ ),
393
+ };
394
+ }
341
395
  const permission: TokenPermission =
342
396
  hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
343
397
  ? "full"