@openparachute/vault 0.3.3 → 0.4.0
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/.parachute/module.json +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/hub-jwt.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub-issued JWT validation. Vault as resource server: trusts tokens that the
|
|
3
|
+
* hub signs against keys we fetch from the hub's `/.well-known/jwks.json`.
|
|
4
|
+
*
|
|
5
|
+
* The trust kernel — JWKS fetch + verify, issuer pin, audience strict-check,
|
|
6
|
+
* RFC 7519 string-or-array `aud` handling — lives in the shared
|
|
7
|
+
* `@openparachute/scope-guard` library so vault, scribe, and paraclaw can't
|
|
8
|
+
* silently drift on the worst place to drift. This file is the vault-side
|
|
9
|
+
* adapter: hub-origin resolution (env-var precedence + loopback fallback),
|
|
10
|
+
* a process-wide guard instance, and re-exports preserving the public
|
|
11
|
+
* surface every existing call site already imports.
|
|
12
|
+
*
|
|
13
|
+
* Vault#169 / hub-as-issuer Phase B2; vault#TBD / scope-guard adoption.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
createScopeGuard,
|
|
17
|
+
HubJwtError,
|
|
18
|
+
type HubJwtClaims,
|
|
19
|
+
looksLikeJwt,
|
|
20
|
+
type ValidateHubJwtOptions,
|
|
21
|
+
} from "@openparachute/scope-guard";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the hub origin used to fetch JWKS and validate `iss`. Strips a
|
|
27
|
+
* trailing slash so we get a single canonical form.
|
|
28
|
+
*
|
|
29
|
+
* Order: env var → loopback fallback. We deliberately don't read
|
|
30
|
+
* `~/.parachute/services.json` — the hub is the dispatcher, not a registered
|
|
31
|
+
* service in that file. If a deployment exposes the hub on a non-default
|
|
32
|
+
* origin, the env var is the contract.
|
|
33
|
+
*/
|
|
34
|
+
export function getHubOrigin(): string {
|
|
35
|
+
const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
36
|
+
if (env && env.length > 0) return env;
|
|
37
|
+
return DEFAULT_HUB_LOOPBACK;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Process-wide guard. The resolver form lets tests flip
|
|
41
|
+
// `PARACHUTE_HUB_ORIGIN` between cases — the lib re-resolves on every
|
|
42
|
+
// `validateHubJwt` and `resetJwksCache` call so the env-var change picks up
|
|
43
|
+
// without a server restart. JWKS cache (5min/30s defaults) lives inside the
|
|
44
|
+
// guard, shared across requests.
|
|
45
|
+
const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
|
|
49
|
+
* failure (bad signature, wrong issuer, expired, missing kid, JWKS
|
|
50
|
+
* unreachable, audience mismatch). On success returns the surfaced claims
|
|
51
|
+
* plus the parsed scope list.
|
|
52
|
+
*
|
|
53
|
+
* Trust model:
|
|
54
|
+
* - `iss` MUST equal the configured hub origin. Without this, anyone could
|
|
55
|
+
* mint a token against any RSA key and pass verification.
|
|
56
|
+
* - `aud` is strict-checked against `opts.expectedAudience` when provided
|
|
57
|
+
* — the resource-server backstop for per-vault binding.
|
|
58
|
+
*
|
|
59
|
+
* Scope-shape policy (e.g. "hub-issued tokens may not carry broad
|
|
60
|
+
* `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
|
|
61
|
+
* not here — this function stays focused on JWT-level concerns.
|
|
62
|
+
*/
|
|
63
|
+
export async function validateHubJwt(
|
|
64
|
+
token: string,
|
|
65
|
+
opts: ValidateHubJwtOptions = {},
|
|
66
|
+
): Promise<HubJwtClaims> {
|
|
67
|
+
return guard.validateHubJwt(token, opts);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset the cached JWKS getter. Tests use this to switch origins between
|
|
72
|
+
* cases; production callers shouldn't need it (origin is process-stable).
|
|
73
|
+
*/
|
|
74
|
+
export function resetJwksCache(): void {
|
|
75
|
+
guard.resetJwksCache();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { HubJwtError, looksLikeJwt };
|
|
79
|
+
export type { HubJwtClaims, ValidateHubJwtOptions };
|
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `parachute-vault init` flag plumbing — the cases
|
|
3
|
+
* where init exits early without touching the daemon, ~/.claude.json, or
|
|
4
|
+
* the vault filesystem. The full happy-path of init isn't run here because
|
|
5
|
+
* it would install a launchd agent on macOS and write into the developer's
|
|
6
|
+
* real ~/Library/LaunchAgents — out of scope for unit tests. The vault-name
|
|
7
|
+
* decision logic is fully covered by `vault-name.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect } from "bun:test";
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { join, resolve } from "path";
|
|
14
|
+
|
|
15
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
16
|
+
|
|
17
|
+
function runCli(args: string[], env: Record<string, string> = {}): {
|
|
18
|
+
exitCode: number;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
} {
|
|
22
|
+
const proc = Bun.spawnSync({
|
|
23
|
+
cmd: ["bun", CLI, ...args],
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
env: { ...process.env, ...env },
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
exitCode: proc.exitCode ?? -1,
|
|
30
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
31
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("vault init — --vault-name validation", () => {
|
|
36
|
+
test("rejects --vault-name with uppercase + space and exits non-zero", () => {
|
|
37
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "My Vault"]);
|
|
38
|
+
expect(exitCode).not.toBe(0);
|
|
39
|
+
expect(stderr).toContain("--vault-name:");
|
|
40
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects --vault-name with a slash and exits non-zero", () => {
|
|
44
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "team/work"]);
|
|
45
|
+
expect(exitCode).not.toBe(0);
|
|
46
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects --vault-name with no value and exits non-zero", () => {
|
|
50
|
+
// `--vault-name` is the last arg → no value follows.
|
|
51
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name"]);
|
|
52
|
+
expect(exitCode).not.toBe(0);
|
|
53
|
+
expect(stderr).toContain("requires a value");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("rejects reserved name 'list' and exits non-zero", () => {
|
|
57
|
+
const { exitCode, stderr } = runCli(["init", "--vault-name", "list"]);
|
|
58
|
+
expect(exitCode).not.toBe(0);
|
|
59
|
+
expect(stderr).toContain("reserved");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("vault init — --help mentions --vault-name", () => {
|
|
64
|
+
test("usage text documents the new flag", () => {
|
|
65
|
+
const { exitCode, stdout } = runCli(["--help"]);
|
|
66
|
+
expect(exitCode).toBe(0);
|
|
67
|
+
expect(stdout).toContain("--vault-name");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("usage text documents --no-autostart (#113)", () => {
|
|
71
|
+
const { exitCode, stdout } = runCli(["--help"]);
|
|
72
|
+
expect(exitCode).toBe(0);
|
|
73
|
+
expect(stdout).toContain("--no-autostart");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* End-to-end init under an isolated $HOME / $PARACHUTE_HOME so we never touch
|
|
79
|
+
* the developer's real ~/.parachute or ~/Library/LaunchAgents. With
|
|
80
|
+
* --no-autostart, init must:
|
|
81
|
+
* 1. Persist `autostart: false` in config.yaml.
|
|
82
|
+
* 2. NOT write the daemon wrapper (start.sh / server-path).
|
|
83
|
+
*
|
|
84
|
+
* --no-mcp / --no-token avoid the ~/.claude.json side effect; HOME=tmpdir
|
|
85
|
+
* makes the launchd-uninstall-prior-registration call land inside the
|
|
86
|
+
* sandbox even on macOS (where uninstallAgent operates on
|
|
87
|
+
* `homedir()/Library/LaunchAgents/...`).
|
|
88
|
+
*/
|
|
89
|
+
describe("vault init — --no-autostart (#113)", () => {
|
|
90
|
+
test("persists autostart=false and skips the daemon wrapper", () => {
|
|
91
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
|
|
92
|
+
try {
|
|
93
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
94
|
+
const { exitCode, stdout } = runCli(
|
|
95
|
+
[
|
|
96
|
+
"init",
|
|
97
|
+
"--no-autostart",
|
|
98
|
+
"--no-mcp",
|
|
99
|
+
"--no-token",
|
|
100
|
+
"--vault-name",
|
|
101
|
+
"autostarttest",
|
|
102
|
+
],
|
|
103
|
+
{ HOME: sandbox, PARACHUTE_HOME: parachuteHome },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(exitCode).toBe(0);
|
|
107
|
+
expect(stdout).toContain("Autostart disabled");
|
|
108
|
+
|
|
109
|
+
const configPath = join(parachuteHome, "vault", "config.yaml");
|
|
110
|
+
expect(existsSync(configPath)).toBe(true);
|
|
111
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
112
|
+
|
|
113
|
+
// Daemon wrapper / pointer are written by installAgent /
|
|
114
|
+
// installSystemdService — neither should run when autostart is off.
|
|
115
|
+
expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
|
|
116
|
+
expect(existsSync(join(parachuteHome, "vault", "server-path"))).toBe(false);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("re-running init without a flag preserves persisted autostart=false", () => {
|
|
123
|
+
// We can't drive the --autostart re-run end-to-end here: it calls
|
|
124
|
+
// installAgent() / installSystemdService() which write to launchd /
|
|
125
|
+
// systemd state outside the PARACHUTE_HOME sandbox, breaking test
|
|
126
|
+
// hermeticity. Instead verify the inverse property — that a no-flag
|
|
127
|
+
// re-run honors the persisted opt-out and does NOT fall back to the
|
|
128
|
+
// default-on. This is the actual user-facing risk (forgetting to pass
|
|
129
|
+
// --no-autostart on every re-run shouldn't re-enable the daemon).
|
|
130
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
|
|
131
|
+
try {
|
|
132
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
133
|
+
const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
|
|
134
|
+
|
|
135
|
+
const first = runCli(
|
|
136
|
+
[
|
|
137
|
+
"init",
|
|
138
|
+
"--no-autostart",
|
|
139
|
+
"--no-mcp",
|
|
140
|
+
"--no-token",
|
|
141
|
+
"--vault-name",
|
|
142
|
+
"autostarttest",
|
|
143
|
+
],
|
|
144
|
+
env,
|
|
145
|
+
);
|
|
146
|
+
expect(first.exitCode).toBe(0);
|
|
147
|
+
|
|
148
|
+
const configPath = join(parachuteHome, "vault", "config.yaml");
|
|
149
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
150
|
+
|
|
151
|
+
// No --autostart / --no-autostart on this run; init should read the
|
|
152
|
+
// persisted false and skip daemon install again.
|
|
153
|
+
const second = runCli(["init", "--no-mcp", "--no-token"], env);
|
|
154
|
+
expect(second.exitCode).toBe(0);
|
|
155
|
+
expect(second.stdout).toContain("Autostart disabled");
|
|
156
|
+
expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
|
|
157
|
+
expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
|
|
158
|
+
} finally {
|
|
159
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* #210: re-running `parachute-vault init` is the documented recovery path
|
|
166
|
+
* for installs whose `services.json` is stale (#208 left some vaults out of
|
|
167
|
+
* the manifest). The recovery is implicit — init re-registers the full
|
|
168
|
+
* vault list every run via `buildVaultServicePaths` — so this test pins it
|
|
169
|
+
* down with an explicit fixture: corrupt the manifest to drop one vault,
|
|
170
|
+
* re-run init, expect the manifest to grow back.
|
|
171
|
+
*/
|
|
172
|
+
describe("vault init — repairs stale services.json (#210)", () => {
|
|
173
|
+
test("re-running init rewrites services.json to include every vault on disk", () => {
|
|
174
|
+
const sandbox = mkdtempSync(join(tmpdir(), "vault-init-repair-"));
|
|
175
|
+
try {
|
|
176
|
+
const parachuteHome = join(sandbox, ".parachute");
|
|
177
|
+
const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
|
|
178
|
+
|
|
179
|
+
// Use `create` to bootstrap two vaults into a real, healthy state —
|
|
180
|
+
// this also writes the initial services.json with both vaults so we
|
|
181
|
+
// have a known-good baseline to corrupt.
|
|
182
|
+
expect(runCli(["create", "alpha", "--json"], env).exitCode).toBe(0);
|
|
183
|
+
expect(runCli(["create", "beta", "--json"], env).exitCode).toBe(0);
|
|
184
|
+
|
|
185
|
+
const servicesPath = join(parachuteHome, "services.json");
|
|
186
|
+
const baseline = JSON.parse(readFileSync(servicesPath, "utf-8"));
|
|
187
|
+
const baselineEntry = baseline.services.find(
|
|
188
|
+
(s: { name: string }) => s.name === "parachute-vault",
|
|
189
|
+
);
|
|
190
|
+
expect(baselineEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
191
|
+
|
|
192
|
+
// Corrupt: drop beta from the manifest, mimicking the #208 state where
|
|
193
|
+
// an older `create` ran without the upsert.
|
|
194
|
+
baselineEntry.paths = ["/vault/alpha"];
|
|
195
|
+
writeFileSync(servicesPath, JSON.stringify(baseline, null, 2));
|
|
196
|
+
|
|
197
|
+
// Re-run init with no flags that would change vault topology. The
|
|
198
|
+
// sandbox env keeps launchd / ~/.claude.json side effects out of the
|
|
199
|
+
// dev environment.
|
|
200
|
+
const repair = runCli(
|
|
201
|
+
["init", "--no-autostart", "--no-mcp", "--no-token"],
|
|
202
|
+
env,
|
|
203
|
+
);
|
|
204
|
+
expect(repair.exitCode).toBe(0);
|
|
205
|
+
|
|
206
|
+
const repaired = JSON.parse(readFileSync(servicesPath, "utf-8"));
|
|
207
|
+
const repairedEntry = repaired.services.find(
|
|
208
|
+
(s: { name: string }) => s.name === "parachute-vault",
|
|
209
|
+
);
|
|
210
|
+
// alpha is still default (created first), so it leads. beta is back.
|
|
211
|
+
expect(repairedEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
|
|
212
|
+
} finally {
|
|
213
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/mcp-http.ts
CHANGED
|
@@ -24,47 +24,49 @@ import {
|
|
|
24
24
|
McpError,
|
|
25
25
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
26
26
|
import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
|
|
27
|
-
import { requireScope } from "./auth.ts";
|
|
28
27
|
import type { AuthResult } from "./auth.ts";
|
|
29
28
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
30
|
-
import {
|
|
29
|
+
import { hasScopeForVault } from "./scopes.ts";
|
|
30
|
+
import type { VaultVerb } from "./scopes.ts";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Required
|
|
34
|
-
*
|
|
35
|
-
* read
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* Required verb for each MCP tool. Tools that mutate note/tag state require
|
|
34
|
+
* write; pure query tools need read. `vault-info` is listed as read because
|
|
35
|
+
* read-only callers can fetch stats — the description-update branch inside
|
|
36
|
+
* vault-info performs its own secondary write check (see `overrideVaultInfo`
|
|
37
|
+
* in mcp-tools.ts). Do not assume the outer gate alone protects the inner
|
|
38
|
+
* branch.
|
|
39
39
|
*/
|
|
40
|
-
const
|
|
41
|
-
"query-notes":
|
|
42
|
-
"list-tags":
|
|
43
|
-
"find-path":
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
40
|
+
const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
|
|
41
|
+
"query-notes": "read",
|
|
42
|
+
"list-tags": "read",
|
|
43
|
+
"find-path": "read",
|
|
44
|
+
"synthesize-notes": "read",
|
|
45
|
+
"vault-info": "read",
|
|
46
|
+
"create-note": "write",
|
|
47
|
+
"update-note": "write",
|
|
48
|
+
"delete-note": "write",
|
|
49
|
+
"update-tag": "write",
|
|
50
|
+
"delete-tag": "write",
|
|
50
51
|
};
|
|
51
52
|
|
|
52
|
-
function
|
|
53
|
+
function requiredVerbForTool(toolName: string): VaultVerb {
|
|
53
54
|
// Default-deny: unknown tools require write. Keeps accidental reads of
|
|
54
55
|
// a not-yet-mapped mutation tool from slipping past.
|
|
55
|
-
return
|
|
56
|
+
return TOOL_REQUIRED_VERB[toolName] ?? "write";
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** Handle scoped MCP at /vault/{name}/mcp (single vault). */
|
|
59
60
|
export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
|
|
60
61
|
const instruction = getServerInstruction(vaultName);
|
|
61
|
-
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
|
|
62
|
+
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
async function handleMcp(
|
|
65
66
|
req: Request,
|
|
66
67
|
getTools: () => McpToolDef[],
|
|
67
68
|
serverName: string,
|
|
69
|
+
vaultName: string,
|
|
68
70
|
auth: AuthResult,
|
|
69
71
|
instruction: string,
|
|
70
72
|
): Promise<Response> {
|
|
@@ -84,11 +86,11 @@ async function handleMcp(
|
|
|
84
86
|
const mcpTools = getTools();
|
|
85
87
|
|
|
86
88
|
// Filter the advertised tool list to what the caller's scopes actually
|
|
87
|
-
// permit. Callers without
|
|
88
|
-
// matches the prior behavior of the read/full permission model but
|
|
89
|
-
// driven by scope inheritance.
|
|
89
|
+
// permit for THIS vault. Callers without write don't see mutation tools at
|
|
90
|
+
// all — matches the prior behavior of the read/full permission model but
|
|
91
|
+
// now driven by per-vault scope inheritance.
|
|
90
92
|
const visibleTools = mcpTools.filter((t) =>
|
|
91
|
-
|
|
93
|
+
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t.name)),
|
|
92
94
|
);
|
|
93
95
|
|
|
94
96
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -102,12 +104,12 @@ async function handleMcp(
|
|
|
102
104
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
103
105
|
const { name, arguments: args } = request.params;
|
|
104
106
|
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
+
const neededVerb = requiredVerbForTool(name);
|
|
108
|
+
if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
|
|
107
109
|
return {
|
|
108
110
|
content: [{
|
|
109
111
|
type: "text" as const,
|
|
110
|
-
text: `Forbidden: tool '${name}' requires the '
|
|
112
|
+
text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
|
|
111
113
|
}],
|
|
112
114
|
isError: true,
|
|
113
115
|
};
|
package/src/mcp-install.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
|
|
|
20
20
|
export function chooseMcpUrl(
|
|
21
21
|
vaultName: string,
|
|
22
22
|
port: number,
|
|
23
|
-
env: { PARACHUTE_HUB_ORIGIN?: string } = process.env,
|
|
23
|
+
env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
|
|
24
24
|
): { url: string; source: McpUrlSource } {
|
|
25
25
|
const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
26
26
|
if (hub) {
|