@openparachute/vault 0.4.9-rc.2 → 0.4.9-rc.4

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/vault.test.ts CHANGED
@@ -3941,6 +3941,10 @@ describe("stateless MCP transport", async () => {
3941
3941
  expect(toolNames).not.toContain("delete-note");
3942
3942
  expect(toolNames).not.toContain("update-tag");
3943
3943
  expect(toolNames).not.toContain("delete-tag");
3944
+ // Admin tools (vault#376) are hidden too
3945
+ expect(toolNames).not.toContain("manage-token");
3946
+ // Read tier is exactly 4 tools.
3947
+ expect(toolNames.length).toBe(4);
3944
3948
 
3945
3949
  closeAllStores();
3946
3950
  });
@@ -4077,7 +4081,13 @@ describe("stateless MCP transport", async () => {
4077
4081
  expect(res.status).toBe(200); // JSON-RPC envelope is 200 even for tool errors
4078
4082
  const body = await res.json() as any;
4079
4083
  expect(body.result.isError).toBe(true);
4080
- expect(body.result.content[0].text).toContain("vault:write");
4084
+ // Post-vault#376: hidden tools surface as "Unknown tool" rather than
4085
+ // a verb-specific Forbidden — see mcp-http.ts dispatch-against-
4086
+ // visibleTools rationale. The contract is: tools not in tools/list
4087
+ // also can't be called explicitly. (Differential errors would leak
4088
+ // the existence of admin-only tools to write-scope sessions.)
4089
+ expect(body.result.content[0].text).toContain("Unknown tool");
4090
+ expect(body.result.content[0].text).toContain("create-note");
4081
4091
 
4082
4092
  closeAllStores();
4083
4093
  });
@@ -4129,6 +4139,375 @@ describe("stateless MCP transport", async () => {
4129
4139
  });
4130
4140
  });
4131
4141
 
4142
+ // ===========================================================================
4143
+ // vault#376 — Change 1: scope-filtered tool listing across all three tiers
4144
+ // ===========================================================================
4145
+
4146
+ describe("MCP tools/list scope tiers (vault#376)", () => {
4147
+ async function listToolNames(scopes: string[], scopedTags: string[] | null = null, vaultPrefix = "scope-tier") {
4148
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4149
+ const { writeVaultConfig } = await import("./config.ts");
4150
+ const { closeAllStores } = await import("./vault-store.ts");
4151
+
4152
+ const vaultName = `${vaultPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4153
+ writeVaultConfig({
4154
+ name: vaultName,
4155
+ api_keys: [],
4156
+ created_at: new Date().toISOString(),
4157
+ });
4158
+
4159
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4160
+ method: "POST",
4161
+ headers: {
4162
+ "content-type": "application/json",
4163
+ "accept": "application/json, text/event-stream",
4164
+ },
4165
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
4166
+ });
4167
+
4168
+ const res = await handleScopedMcp(req, vaultName, {
4169
+ permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
4170
+ scopes,
4171
+ legacyDerived: false,
4172
+ scoped_tags: scopedTags,
4173
+ } as any);
4174
+ const body = await res.json() as any;
4175
+ const names: string[] = body.result.tools.map((t: any) => t.name);
4176
+ closeAllStores();
4177
+ return names;
4178
+ }
4179
+
4180
+ test("vault:read sees exactly the 4 read tools", async () => {
4181
+ const names = await listToolNames(["vault:read"]);
4182
+ expect(new Set(names)).toEqual(
4183
+ new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
4184
+ );
4185
+ expect(names.length).toBe(4);
4186
+ });
4187
+
4188
+ test("vault:read + vault:write sees the 9 read+write tools", async () => {
4189
+ const names = await listToolNames(["vault:read", "vault:write"]);
4190
+ expect(new Set(names)).toEqual(
4191
+ new Set([
4192
+ "query-notes",
4193
+ "list-tags",
4194
+ "find-path",
4195
+ "vault-info",
4196
+ "create-note",
4197
+ "update-note",
4198
+ "delete-note",
4199
+ "update-tag",
4200
+ "delete-tag",
4201
+ ]),
4202
+ );
4203
+ expect(names.length).toBe(9);
4204
+ expect(names).not.toContain("manage-token");
4205
+ // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
4206
+ // update). Only manage-token is admin-gated.
4207
+ expect(names).toContain("delete-note");
4208
+ expect(names).toContain("delete-tag");
4209
+ });
4210
+
4211
+ test("vault:admin sees all 10 tools including manage-token", async () => {
4212
+ const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
4213
+ expect(names).toContain("manage-token");
4214
+ expect(names.length).toBe(10);
4215
+ });
4216
+
4217
+ test("legacy-derived full token sees all 10 tools (back-compat)", async () => {
4218
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4219
+ const { writeVaultConfig } = await import("./config.ts");
4220
+ const { closeAllStores } = await import("./vault-store.ts");
4221
+
4222
+ const vaultName = `legacy-token-${Date.now()}`;
4223
+ writeVaultConfig({
4224
+ name: vaultName,
4225
+ api_keys: [],
4226
+ created_at: new Date().toISOString(),
4227
+ });
4228
+
4229
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4230
+ method: "POST",
4231
+ headers: {
4232
+ "content-type": "application/json",
4233
+ "accept": "application/json, text/event-stream",
4234
+ },
4235
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
4236
+ });
4237
+
4238
+ // Legacy permission-derived token: legacyDerived=true, scopes carry the
4239
+ // full admin set per `legacyPermissionToScopes("full")`. Compat shim
4240
+ // means the operator's existing pvt_* tokens minted pre-scope-column
4241
+ // see the full surface (including manage-token), not just the 9 they
4242
+ // had before.
4243
+ const res = await handleScopedMcp(req, vaultName, {
4244
+ permission: "full",
4245
+ scopes: ["vault:read", "vault:write", "vault:admin"],
4246
+ legacyDerived: true,
4247
+ scoped_tags: null,
4248
+ } as any);
4249
+ const body = await res.json() as any;
4250
+ const names: string[] = body.result.tools.map((t: any) => t.name);
4251
+ expect(names.length).toBe(10);
4252
+ expect(names).toContain("manage-token");
4253
+ closeAllStores();
4254
+ });
4255
+
4256
+ test("excluded tools surface as 'Unknown tool' if called explicitly", async () => {
4257
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4258
+ const { writeVaultConfig } = await import("./config.ts");
4259
+ const { closeAllStores } = await import("./vault-store.ts");
4260
+
4261
+ const vaultName = `hidden-call-${Date.now()}`;
4262
+ writeVaultConfig({
4263
+ name: vaultName,
4264
+ api_keys: [],
4265
+ created_at: new Date().toISOString(),
4266
+ });
4267
+
4268
+ // Write-scope session calling manage-token (admin-only): should look
4269
+ // like the tool doesn't exist, not "Forbidden: requires vault:admin".
4270
+ // Differential messages would leak the admin tool's existence.
4271
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4272
+ method: "POST",
4273
+ headers: {
4274
+ "content-type": "application/json",
4275
+ "accept": "application/json, text/event-stream",
4276
+ },
4277
+ body: JSON.stringify({
4278
+ jsonrpc: "2.0",
4279
+ id: 1,
4280
+ method: "tools/call",
4281
+ params: { name: "manage-token", arguments: { action: "list" } },
4282
+ }),
4283
+ });
4284
+
4285
+ const res = await handleScopedMcp(req, vaultName, {
4286
+ permission: "full",
4287
+ scopes: ["vault:read", "vault:write"],
4288
+ legacyDerived: false,
4289
+ scoped_tags: null,
4290
+ } as any);
4291
+ const body = await res.json() as any;
4292
+ expect(body.result.isError).toBe(true);
4293
+ expect(body.result.content[0].text).toContain("Unknown tool");
4294
+ expect(body.result.content[0].text).toContain("manage-token");
4295
+ expect(body.result.content[0].text).not.toContain("vault:admin");
4296
+ closeAllStores();
4297
+ });
4298
+ });
4299
+
4300
+ // ===========================================================================
4301
+ // vault#376 — Change 2: manage-token mint/revoke/list
4302
+ // ===========================================================================
4303
+
4304
+ describe("manage-token MCP tool (vault#376)", () => {
4305
+ async function callTool(
4306
+ vaultName: string,
4307
+ auth: any,
4308
+ toolName: string,
4309
+ args: Record<string, unknown>,
4310
+ ) {
4311
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4312
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4313
+ method: "POST",
4314
+ headers: {
4315
+ "content-type": "application/json",
4316
+ "accept": "application/json, text/event-stream",
4317
+ },
4318
+ body: JSON.stringify({
4319
+ jsonrpc: "2.0",
4320
+ id: 1,
4321
+ method: "tools/call",
4322
+ params: { name: toolName, arguments: args },
4323
+ }),
4324
+ });
4325
+ const res = await handleScopedMcp(req, vaultName, auth);
4326
+ const body = await res.json() as any;
4327
+ if (body.result?.content?.[0]?.text) {
4328
+ try {
4329
+ return { isError: !!body.result.isError, parsed: JSON.parse(body.result.content[0].text), raw: body };
4330
+ } catch {
4331
+ return { isError: !!body.result.isError, parsed: null, raw: body, text: body.result.content[0].text };
4332
+ }
4333
+ }
4334
+ return { isError: false, parsed: null, raw: body };
4335
+ }
4336
+
4337
+ async function setupAdminSession(prefix: string) {
4338
+ const { writeVaultConfig } = await import("./config.ts");
4339
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4340
+ writeVaultConfig({
4341
+ name: vaultName,
4342
+ api_keys: [],
4343
+ created_at: new Date().toISOString(),
4344
+ });
4345
+ // Stable caller_jti so list/revoke can find the mints; we don't go
4346
+ // through the actual auth flow here (that would require a real pvt_
4347
+ // token; the unit-level test point is the manage-token logic itself).
4348
+ const auth: any = {
4349
+ permission: "full",
4350
+ scopes: ["vault:read", "vault:write", "vault:admin"],
4351
+ legacyDerived: false,
4352
+ scoped_tags: null,
4353
+ vault_name: vaultName,
4354
+ caller_jti: `t_session${Math.random().toString(36).slice(2, 12)}`,
4355
+ };
4356
+ return { vaultName, auth };
4357
+ }
4358
+
4359
+ test("mint with default TTL returns valid token + jti + expires_at ~15min out", async () => {
4360
+ const { vaultName, auth } = await setupAdminSession("mint-default");
4361
+ const { closeAllStores } = await import("./vault-store.ts");
4362
+ const before = Date.now();
4363
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4364
+ action: "mint",
4365
+ scope: "vault:read",
4366
+ });
4367
+ expect(parsed.action).toBe("mint");
4368
+ expect(parsed.token).toMatch(/^pvt_/);
4369
+ expect(parsed.jti).toMatch(/^t_/);
4370
+ const expiresAt = Date.parse(parsed.expires_at);
4371
+ expect(expiresAt - before).toBeGreaterThan(890 * 1000);
4372
+ expect(expiresAt - before).toBeLessThan(910 * 1000);
4373
+ closeAllStores();
4374
+ });
4375
+
4376
+ test("mint with custom TTL=3600 returns expires_at ~1 hour out", async () => {
4377
+ const { vaultName, auth } = await setupAdminSession("mint-max");
4378
+ const { closeAllStores } = await import("./vault-store.ts");
4379
+ const before = Date.now();
4380
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4381
+ action: "mint",
4382
+ scope: "vault:read",
4383
+ ttl_seconds: 3600,
4384
+ });
4385
+ expect(parsed.action).toBe("mint");
4386
+ const expiresAt = Date.parse(parsed.expires_at);
4387
+ expect(expiresAt - before).toBeGreaterThan(3590 * 1000);
4388
+ expect(expiresAt - before).toBeLessThan(3610 * 1000);
4389
+ closeAllStores();
4390
+ });
4391
+
4392
+ test("mint with TTL=0 is rejected", async () => {
4393
+ const { vaultName, auth } = await setupAdminSession("mint-zero");
4394
+ const { closeAllStores } = await import("./vault-store.ts");
4395
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4396
+ action: "mint",
4397
+ scope: "vault:read",
4398
+ ttl_seconds: 0,
4399
+ });
4400
+ expect(parsed.error).toBe("invalid_request");
4401
+ closeAllStores();
4402
+ });
4403
+
4404
+ test("mint with TTL=3601 is rejected (over the 3600 cap)", async () => {
4405
+ const { vaultName, auth } = await setupAdminSession("mint-over");
4406
+ const { closeAllStores } = await import("./vault-store.ts");
4407
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4408
+ action: "mint",
4409
+ scope: "vault:read",
4410
+ ttl_seconds: 3601,
4411
+ });
4412
+ expect(parsed.error).toBe("invalid_request");
4413
+ closeAllStores();
4414
+ });
4415
+
4416
+ test("mint with scope outside caller's subset is rejected", async () => {
4417
+ const { vaultName, auth } = await setupAdminSession("mint-subset");
4418
+ const { closeAllStores } = await import("./vault-store.ts");
4419
+ // Caller's auth carries admin/write/read for THIS vault. Asking for a
4420
+ // scope naming a different vault is the canonical privilege-escalation
4421
+ // surface — must reject.
4422
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4423
+ action: "mint",
4424
+ scope: "vault:other-vault:write",
4425
+ });
4426
+ expect(parsed.error).toBe("forbidden");
4427
+ expect(parsed.rejected).toBeDefined();
4428
+ closeAllStores();
4429
+ });
4430
+
4431
+ test("revoke own minted token returns ok=true; second revoke is idempotent", async () => {
4432
+ const { vaultName, auth } = await setupAdminSession("revoke-idem");
4433
+ const { closeAllStores } = await import("./vault-store.ts");
4434
+ const mint = await callTool(vaultName, auth, "manage-token", {
4435
+ action: "mint",
4436
+ scope: "vault:read",
4437
+ });
4438
+ const jti = mint.parsed.jti;
4439
+ const first = await callTool(vaultName, auth, "manage-token", {
4440
+ action: "revoke",
4441
+ jti,
4442
+ });
4443
+ expect(first.parsed.ok).toBe(true);
4444
+ expect(first.parsed.already_revoked).toBe(false);
4445
+ const second = await callTool(vaultName, auth, "manage-token", {
4446
+ action: "revoke",
4447
+ jti,
4448
+ });
4449
+ expect(second.parsed.ok).toBe(true);
4450
+ expect(second.parsed.already_revoked).toBe(true);
4451
+ closeAllStores();
4452
+ });
4453
+
4454
+ test("list returns this session's mints, not other sessions' or CLI mints", async () => {
4455
+ const { vaultName, auth } = await setupAdminSession("list-session");
4456
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4457
+ const { createToken, generateToken } = await import("./token-store.ts");
4458
+
4459
+ // Mint two via manage-token in THIS session.
4460
+ const m1 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "alpha" });
4461
+ const m2 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "beta" });
4462
+
4463
+ // Mint one CLI-style (no created_via) and one from another session.
4464
+ const store = getVaultStore(vaultName);
4465
+ createToken(store.db, generateToken().fullToken, {
4466
+ label: "cli-token",
4467
+ permission: "full",
4468
+ scopes: ["vault:read"],
4469
+ vault_name: vaultName,
4470
+ });
4471
+ createToken(store.db, generateToken().fullToken, {
4472
+ label: "other-session-mint",
4473
+ permission: "full",
4474
+ scopes: ["vault:read"],
4475
+ vault_name: vaultName,
4476
+ created_via: "mcp_mint",
4477
+ parent_jti: "t_othersession",
4478
+ });
4479
+
4480
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4481
+ expect(parsed.action).toBe("list");
4482
+ const jtis = parsed.tokens.map((t: any) => t.jti);
4483
+ expect(jtis).toContain(m1.parsed.jti);
4484
+ expect(jtis).toContain(m2.parsed.jti);
4485
+ expect(parsed.tokens.length).toBe(2);
4486
+ closeAllStores();
4487
+ });
4488
+
4489
+ test("audit-log integration: minted row carries created_via='mcp_mint' and parent_jti", async () => {
4490
+ const { vaultName, auth } = await setupAdminSession("audit");
4491
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4492
+
4493
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4494
+ action: "mint",
4495
+ scope: "vault:read",
4496
+ });
4497
+ const jti = parsed.jti;
4498
+ const store = getVaultStore(vaultName);
4499
+ const hashPrefix = jti.slice(2);
4500
+ const row = store.db.prepare(`
4501
+ SELECT created_via, parent_jti, vault_name FROM tokens
4502
+ WHERE token_hash LIKE ?
4503
+ `).get(`sha256:${hashPrefix}%`) as { created_via: string | null; parent_jti: string | null; vault_name: string | null };
4504
+ expect(row.created_via).toBe("mcp_mint");
4505
+ expect(row.parent_jti).toBe(auth.caller_jti);
4506
+ expect(row.vault_name).toBe(vaultName);
4507
+ closeAllStores();
4508
+ });
4509
+ });
4510
+
4132
4511
  describe("extractApiKey", () => {
4133
4512
  test("extracts from Authorization: Bearer header", () => {
4134
4513
  const req = new Request("http://localhost/api/notes", {