@openparachute/vault 0.3.3 → 0.4.3
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/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- 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 +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- 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 +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -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 +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- 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 +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -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 +727 -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 +1626 -183
- 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/mcp-http.ts
CHANGED
|
@@ -24,47 +24,51 @@ 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
|
-
"vault-info":
|
|
45
|
-
"create-note":
|
|
46
|
-
"update-note":
|
|
47
|
-
"delete-note":
|
|
48
|
-
"update-tag":
|
|
49
|
-
"delete-tag":
|
|
40
|
+
const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
|
|
41
|
+
"query-notes": "read",
|
|
42
|
+
"list-tags": "read",
|
|
43
|
+
"find-path": "read",
|
|
44
|
+
"vault-info": "read",
|
|
45
|
+
"create-note": "write",
|
|
46
|
+
"update-note": "write",
|
|
47
|
+
"delete-note": "write",
|
|
48
|
+
"update-tag": "write",
|
|
49
|
+
"delete-tag": "write",
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
function
|
|
52
|
+
function requiredVerbForTool(toolName: string): VaultVerb {
|
|
53
53
|
// Default-deny: unknown tools require write. Keeps accidental reads of
|
|
54
54
|
// a not-yet-mapped mutation tool from slipping past.
|
|
55
|
-
return
|
|
55
|
+
return TOOL_REQUIRED_VERB[toolName] ?? "write";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Handle scoped MCP at /vault/{name}/mcp (single vault). */
|
|
59
59
|
export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Auth flows through to getServerInstruction so the connect-time
|
|
61
|
+
// markdown brief is filtered by `scoped_tags` — symmetric with the
|
|
62
|
+
// JSON `vault-info` wrapper.
|
|
63
|
+
const instruction = await getServerInstruction(vaultName, auth);
|
|
64
|
+
return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
async function handleMcp(
|
|
65
68
|
req: Request,
|
|
66
69
|
getTools: () => McpToolDef[],
|
|
67
70
|
serverName: string,
|
|
71
|
+
vaultName: string,
|
|
68
72
|
auth: AuthResult,
|
|
69
73
|
instruction: string,
|
|
70
74
|
): Promise<Response> {
|
|
@@ -84,11 +88,11 @@ async function handleMcp(
|
|
|
84
88
|
const mcpTools = getTools();
|
|
85
89
|
|
|
86
90
|
// 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.
|
|
91
|
+
// permit for THIS vault. Callers without write don't see mutation tools at
|
|
92
|
+
// all — matches the prior behavior of the read/full permission model but
|
|
93
|
+
// now driven by per-vault scope inheritance.
|
|
90
94
|
const visibleTools = mcpTools.filter((t) =>
|
|
91
|
-
|
|
95
|
+
hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t.name)),
|
|
92
96
|
);
|
|
93
97
|
|
|
94
98
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -102,12 +106,12 @@ async function handleMcp(
|
|
|
102
106
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
103
107
|
const { name, arguments: args } = request.params;
|
|
104
108
|
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
109
|
+
const neededVerb = requiredVerbForTool(name);
|
|
110
|
+
if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
|
|
107
111
|
return {
|
|
108
112
|
content: [{
|
|
109
113
|
type: "text" as const,
|
|
110
|
-
text: `Forbidden: tool '${name}' requires the '
|
|
114
|
+
text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
|
|
111
115
|
}],
|
|
112
116
|
isError: true,
|
|
113
117
|
};
|
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) {
|
package/src/mcp-tools.ts
CHANGED
|
@@ -7,27 +7,88 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildVaultProjection,
|
|
12
|
+
projectionToMarkdown,
|
|
13
|
+
type VaultProjection,
|
|
14
|
+
} from "../core/src/vault-projection.ts";
|
|
10
15
|
import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
11
16
|
import { getVaultStore } from "./vault-store.ts";
|
|
12
|
-
import {
|
|
17
|
+
import { hasScopeForVault } from "./scopes.ts";
|
|
13
18
|
import type { AuthResult } from "./auth.ts";
|
|
19
|
+
import {
|
|
20
|
+
expandTokenTagScope,
|
|
21
|
+
noteWithinTagScope,
|
|
22
|
+
tagsWithinScope,
|
|
23
|
+
} from "./tag-scope.ts";
|
|
24
|
+
import { findTokensReferencingTag } from "./token-store.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filter a vault projection to entries an in-scope tag contributes to.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the JSON `vault-info` wrapper exactly so the connect-time
|
|
30
|
+
* markdown brief and the JSON tool surface identical scope shapes:
|
|
31
|
+
*
|
|
32
|
+
* - `tags` array → drop entries whose name isn't in `allowed`.
|
|
33
|
+
* - `indexed_fields` array → for each entry, intersect `tags` (the
|
|
34
|
+
* declarer list) with `allowed`. Drop the entry entirely when no
|
|
35
|
+
* declarer survives.
|
|
36
|
+
*
|
|
37
|
+
* Aggregate stats and the static `query_hints` catalog pass through
|
|
38
|
+
* unchanged — counts are aggregate (already pre-#271 behavior) and hints
|
|
39
|
+
* are pure documentation.
|
|
40
|
+
*/
|
|
41
|
+
function filterProjectionByScope(
|
|
42
|
+
projection: VaultProjection,
|
|
43
|
+
allowed: Set<string>,
|
|
44
|
+
): VaultProjection {
|
|
45
|
+
return {
|
|
46
|
+
...projection,
|
|
47
|
+
tags: projection.tags.filter((t) => allowed.has(t.name)),
|
|
48
|
+
indexed_fields: projection.indexed_fields
|
|
49
|
+
.map((f) => ({ ...f, tags: f.tags.filter((t) => allowed.has(t)) }))
|
|
50
|
+
.filter((f) => f.tags.length > 0),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
14
53
|
|
|
15
54
|
/**
|
|
16
55
|
* Get the MCP server instruction for a vault.
|
|
17
|
-
*
|
|
56
|
+
*
|
|
57
|
+
* Sent once at session init via the MCP `initialize` response — not per
|
|
58
|
+
* tool. The body is a markdown brief composed from the same vault projection
|
|
59
|
+
* `vault-info` returns, so an agent has everything it needs to orient
|
|
60
|
+
* itself before issuing a single query. Stats are included so the count
|
|
61
|
+
* line ("N notes, M tags") is always populated.
|
|
62
|
+
*
|
|
63
|
+
* When `auth` carries `scoped_tags`, the projection is filtered to those
|
|
64
|
+
* tags + descendants before rendering — symmetric with the JSON
|
|
65
|
+
* `vault-info` wrapper, so a tag-scoped token never learns about
|
|
66
|
+
* out-of-scope tags via the connect-time brief either. Aggregate counts
|
|
67
|
+
* pass through unchanged (they were pre-existing leak surface; not new).
|
|
68
|
+
*
|
|
69
|
+
* Async because expanding the tag-scope allowlist hits the store's
|
|
70
|
+
* hierarchy resolver. Returns the orientation block even when the vault
|
|
71
|
+
* has no description or schemas — empty vaults still get the query-hint
|
|
72
|
+
* catalog and refresh pointers.
|
|
18
73
|
*/
|
|
19
|
-
export function getServerInstruction(
|
|
74
|
+
export async function getServerInstruction(
|
|
75
|
+
vaultName: string,
|
|
76
|
+
auth?: AuthResult,
|
|
77
|
+
): Promise<string> {
|
|
20
78
|
const config = readVaultConfig(vaultName);
|
|
79
|
+
const store = getVaultStore(vaultName);
|
|
80
|
+
let projection = buildVaultProjection(store.db, { includeStats: true });
|
|
21
81
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (config?.description) {
|
|
27
|
-
parts.push("", config.description);
|
|
82
|
+
if (auth?.scoped_tags && auth.scoped_tags.length > 0) {
|
|
83
|
+
const allowed = await expandTokenTagScope(store, auth.scoped_tags);
|
|
84
|
+
if (allowed) projection = filterProjectionByScope(projection, allowed);
|
|
28
85
|
}
|
|
29
86
|
|
|
30
|
-
return
|
|
87
|
+
return projectionToMarkdown({
|
|
88
|
+
vaultName,
|
|
89
|
+
description: config?.description ?? null,
|
|
90
|
+
projection,
|
|
91
|
+
});
|
|
31
92
|
}
|
|
32
93
|
|
|
33
94
|
/**
|
|
@@ -46,10 +107,241 @@ export function generateScopedMcpTools(vaultName: string, auth?: AuthResult): Mc
|
|
|
46
107
|
const tools = generateMcpTools(store);
|
|
47
108
|
|
|
48
109
|
overrideVaultInfo(tools, vaultName, auth);
|
|
110
|
+
applyTagDependencyGuards(tools, vaultName);
|
|
111
|
+
applyTagScopeWrappers(tools, vaultName, auth);
|
|
49
112
|
|
|
50
113
|
return tools;
|
|
51
114
|
}
|
|
52
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Tag-delete and (future) tag-merge always check for tag-scoped tokens
|
|
118
|
+
* referencing the doomed tag — regardless of whether the *deleter* is
|
|
119
|
+
* itself tag-scoped. A successful delete that orphans an allowlist would
|
|
120
|
+
* silently widen surface area downstream. Mirrors the REST 409
|
|
121
|
+
* `tag_in_use_by_tokens` envelope.
|
|
122
|
+
*/
|
|
123
|
+
function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
|
|
124
|
+
const store = getVaultStore(vaultName);
|
|
125
|
+
wrapReadTool(tools, "delete-tag", async (orig, params) => {
|
|
126
|
+
const tag = (params as any).tag ?? (params as any).name;
|
|
127
|
+
if (typeof tag === "string") {
|
|
128
|
+
const referenced_by = findTokensReferencingTag(store.db, tag);
|
|
129
|
+
if (referenced_by.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
error: "TagInUseByTokens",
|
|
132
|
+
error_type: "tag_in_use_by_tokens",
|
|
133
|
+
message: `Tag "${tag}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
|
|
134
|
+
tag,
|
|
135
|
+
referenced_by,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return await orig(params);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap read-tool execute() functions to filter results down to what the
|
|
145
|
+
* token's `scoped_tags` allowlist permits. No-op when the token is
|
|
146
|
+
* unscoped — the wrappers fast-path on `auth.scoped_tags === null` so
|
|
147
|
+
* unscoped sessions retain identical pre-tag-scope behavior.
|
|
148
|
+
*
|
|
149
|
+
* Read tools handled here:
|
|
150
|
+
* - query-notes: filter single-note returns + result lists
|
|
151
|
+
* - list-tags: filter to allowlisted tags + descendants
|
|
152
|
+
* - find-path: require both endpoints (and every hop) in scope
|
|
153
|
+
* - vault-info: filter projection.tags + projection.indexed_fields
|
|
154
|
+
* to entries an in-scope tag contributes to
|
|
155
|
+
*
|
|
156
|
+
* Write-tool gating happens in handleScopedMcp at the verb-scope layer
|
|
157
|
+
* AND inside each tool's wrapper here (so a tag-scoped `vault:write`
|
|
158
|
+
* token can't write outside its allowlist). See applyTagScopeWriteGuards.
|
|
159
|
+
*/
|
|
160
|
+
function applyTagScopeWrappers(
|
|
161
|
+
tools: McpToolDef[],
|
|
162
|
+
vaultName: string,
|
|
163
|
+
auth: AuthResult | undefined,
|
|
164
|
+
): void {
|
|
165
|
+
if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
|
|
166
|
+
const store = getVaultStore(vaultName);
|
|
167
|
+
// Lazy: only build the expanded allowlist on first tool call.
|
|
168
|
+
let allowedPromise: Promise<Set<string> | null> | null = null;
|
|
169
|
+
const getAllowed = (): Promise<Set<string> | null> => {
|
|
170
|
+
if (!allowedPromise) {
|
|
171
|
+
allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
|
|
172
|
+
}
|
|
173
|
+
return allowedPromise;
|
|
174
|
+
};
|
|
175
|
+
const rawTags = auth.scoped_tags;
|
|
176
|
+
|
|
177
|
+
wrapReadTool(tools, "query-notes", async (orig, params) => {
|
|
178
|
+
const allowed = await getAllowed();
|
|
179
|
+
const result = await orig(params);
|
|
180
|
+
if (!allowed) return result;
|
|
181
|
+
// Single-note shape (`{...note}` with `id`) vs list shape (array).
|
|
182
|
+
if (Array.isArray(result)) {
|
|
183
|
+
return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
|
|
184
|
+
}
|
|
185
|
+
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
186
|
+
return noteWithinTagScope(result as any, allowed, rawTags)
|
|
187
|
+
? result
|
|
188
|
+
: { error: "Note not found", id: (result as any).id };
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
wrapReadTool(tools, "list-tags", async (orig, params) => {
|
|
194
|
+
const allowed = await getAllowed();
|
|
195
|
+
const result = await orig(params);
|
|
196
|
+
if (!allowed || !Array.isArray(result)) return result;
|
|
197
|
+
return result.filter((t: any) => allowed.has(t.name));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
wrapReadTool(tools, "find-path", async (orig, params) => {
|
|
201
|
+
const allowed = await getAllowed();
|
|
202
|
+
const result = await orig(params);
|
|
203
|
+
if (!allowed || !result || typeof result !== "object" || !("path" in result)) return result;
|
|
204
|
+
const ids = (result as any).path as string[];
|
|
205
|
+
for (const id of ids) {
|
|
206
|
+
const note = await store.getNote(id);
|
|
207
|
+
if (!note || !noteWithinTagScope(note, allowed, rawTags)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// vault-info projection (#271): filter the tags catalog to in-scope tags
|
|
215
|
+
// and the indexed_fields catalog to fields with at least one in-scope
|
|
216
|
+
// declarer. Within each surviving indexed_fields entry, also drop
|
|
217
|
+
// out-of-scope declarer names from the `tags` array — a token scoped to
|
|
218
|
+
// `task` shouldn't learn that `project` declares `status` too. Other
|
|
219
|
+
// top-level keys (name, description, query_hints, stats) pass through:
|
|
220
|
+
// counts are aggregate and existing pre-#271 behavior already returned
|
|
221
|
+
// them to scoped tokens. The same `filterProjectionByScope` helper backs
|
|
222
|
+
// `getServerInstruction` so the JSON tool and the connect-time markdown
|
|
223
|
+
// brief stay in lockstep.
|
|
224
|
+
wrapReadTool(tools, "vault-info", async (orig, params) => {
|
|
225
|
+
const allowed = await getAllowed();
|
|
226
|
+
const result = await orig(params);
|
|
227
|
+
if (!allowed || !result || typeof result !== "object") return result;
|
|
228
|
+
const r = result as Record<string, unknown> & Partial<VaultProjection>;
|
|
229
|
+
const partial: VaultProjection = {
|
|
230
|
+
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
231
|
+
indexed_fields: Array.isArray(r.indexed_fields) ? r.indexed_fields : [],
|
|
232
|
+
query_hints: Array.isArray(r.query_hints) ? r.query_hints : [],
|
|
233
|
+
};
|
|
234
|
+
const filtered = filterProjectionByScope(partial, allowed);
|
|
235
|
+
r.tags = filtered.tags;
|
|
236
|
+
r.indexed_fields = filtered.indexed_fields;
|
|
237
|
+
return r;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---- Write-side guards ----
|
|
241
|
+
//
|
|
242
|
+
// The verb-scope check (`vault:write`) is enforced at the dispatch layer
|
|
243
|
+
// in handleScopedMcp. These wrappers add the second axis: a scoped
|
|
244
|
+
// `vault:write` token can only mutate within its tag-allowlist, never
|
|
245
|
+
// outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
|
|
246
|
+
// tag name itself; note operations gate on the prospective tag set.
|
|
247
|
+
|
|
248
|
+
const forbidden = (msg: string): unknown => ({
|
|
249
|
+
error: "Forbidden",
|
|
250
|
+
error_type: "tag_scope_violation",
|
|
251
|
+
message: `${msg} (token tag-allowlist: ${rawTags.join(", ")})`,
|
|
252
|
+
scoped_tags: rawTags,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
wrapReadTool(tools, "create-note", async (orig, params) => {
|
|
256
|
+
const allowed = await getAllowed();
|
|
257
|
+
if (!allowed) return await orig(params);
|
|
258
|
+
// Single or batch shape: `{notes: [...]}` is the batch form (mirrors HTTP).
|
|
259
|
+
const items = Array.isArray((params as any).notes)
|
|
260
|
+
? (params as any).notes
|
|
261
|
+
: [params];
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
const itemTags = Array.isArray((item as any).tags) ? ((item as any).tags as string[]) : [];
|
|
264
|
+
if (!tagsWithinScope(itemTags, allowed, rawTags)) {
|
|
265
|
+
return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return await orig(params);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
wrapReadTool(tools, "update-note", async (orig, params) => {
|
|
272
|
+
const allowed = await getAllowed();
|
|
273
|
+
if (!allowed) return await orig(params);
|
|
274
|
+
const items = Array.isArray((params as any).notes)
|
|
275
|
+
? (params as any).notes
|
|
276
|
+
: [params];
|
|
277
|
+
for (const item of items) {
|
|
278
|
+
const id = (item as any).id ?? (item as any).note_id;
|
|
279
|
+
if (!id) continue;
|
|
280
|
+
const existing = await store.getNote(id as string);
|
|
281
|
+
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
282
|
+
return { error: "Note not found", id };
|
|
283
|
+
}
|
|
284
|
+
const removed = new Set<string>((item as any).tags?.remove ?? []);
|
|
285
|
+
const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
|
|
286
|
+
for (const t of ((item as any).tags?.add ?? []) as string[]) projected.add(t);
|
|
287
|
+
if (!tagsWithinScope([...projected], allowed, rawTags)) {
|
|
288
|
+
return forbidden("update-note: post-update tag set must satisfy the token's allowlist");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return await orig(params);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
wrapReadTool(tools, "delete-note", async (orig, params) => {
|
|
295
|
+
const allowed = await getAllowed();
|
|
296
|
+
if (!allowed) return await orig(params);
|
|
297
|
+
const id = (params as any).id ?? (params as any).note_id;
|
|
298
|
+
if (id) {
|
|
299
|
+
const existing = await store.getNote(id as string);
|
|
300
|
+
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
301
|
+
return { error: "Note not found", id };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return await orig(params);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
wrapReadTool(tools, "update-tag", async (orig, params) => {
|
|
308
|
+
const allowed = await getAllowed();
|
|
309
|
+
if (!allowed) return await orig(params);
|
|
310
|
+
const tag = (params as any).tag ?? (params as any).name;
|
|
311
|
+
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
312
|
+
return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
|
|
313
|
+
}
|
|
314
|
+
return await orig(params);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
wrapReadTool(tools, "delete-tag", async (orig, params) => {
|
|
318
|
+
const allowed = await getAllowed();
|
|
319
|
+
if (!allowed) return await orig(params);
|
|
320
|
+
const tag = (params as any).tag ?? (params as any).name;
|
|
321
|
+
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
322
|
+
return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
|
|
323
|
+
}
|
|
324
|
+
return await orig(params);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function wrapReadTool(
|
|
330
|
+
tools: McpToolDef[],
|
|
331
|
+
name: string,
|
|
332
|
+
wrapper: (orig: (params: Record<string, unknown>) => Promise<unknown>, params: Record<string, unknown>) => Promise<unknown>,
|
|
333
|
+
): void {
|
|
334
|
+
const tool = tools.find((t) => t.name === name);
|
|
335
|
+
if (!tool) return;
|
|
336
|
+
// McpToolDef.execute returns `unknown | Promise<unknown>` (sync OR async).
|
|
337
|
+
// Adapt to the wrapper's strictly-async signature so wrappers can `await
|
|
338
|
+
// orig(params)` uniformly without re-checking each tool.
|
|
339
|
+
const orig = tool.execute;
|
|
340
|
+
const origAsync = (params: Record<string, unknown>): Promise<unknown> =>
|
|
341
|
+
Promise.resolve(orig(params));
|
|
342
|
+
tool.execute = (params) => wrapper(origAsync, params);
|
|
343
|
+
}
|
|
344
|
+
|
|
53
345
|
function overrideVaultInfo(
|
|
54
346
|
tools: McpToolDef[],
|
|
55
347
|
vaultName: string,
|
|
@@ -64,26 +356,33 @@ function overrideVaultInfo(
|
|
|
64
356
|
|
|
65
357
|
if (params.description !== undefined) {
|
|
66
358
|
// Secondary scope check: vault-info is read-gated so read-only callers
|
|
67
|
-
// can fetch stats, but mutating the vault description requires write
|
|
68
|
-
// Without this, a vault:read token could bypass the
|
|
69
|
-
// passing `description` to a tool the outer gate
|
|
70
|
-
|
|
359
|
+
// can fetch stats, but mutating the vault description requires write
|
|
360
|
+
// for THIS vault. Without this, a vault:read token could bypass the
|
|
361
|
+
// outer gate by passing `description` to a tool the outer gate
|
|
362
|
+
// considers read-only.
|
|
363
|
+
if (!auth || !hasScopeForVault(auth.scopes, vaultName, "write")) {
|
|
71
364
|
throw new Error(
|
|
72
|
-
`Forbidden: updating the vault description requires the '
|
|
365
|
+
`Forbidden: updating the vault description requires the 'vault:write' scope (or 'vault:${vaultName}:write'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
|
|
73
366
|
);
|
|
74
367
|
}
|
|
75
368
|
config.description = params.description as string;
|
|
76
369
|
writeVaultConfig(config);
|
|
77
370
|
}
|
|
78
371
|
|
|
79
|
-
const
|
|
372
|
+
const store = getVaultStore(vaultName);
|
|
373
|
+
const includeStats = Boolean(params.include_stats);
|
|
374
|
+
const projection = buildVaultProjection(store.db, { includeStats });
|
|
375
|
+
|
|
376
|
+
const result: Record<string, unknown> = {
|
|
80
377
|
name: config.name,
|
|
81
378
|
description: config.description ?? null,
|
|
379
|
+
tags: projection.tags,
|
|
380
|
+
indexed_fields: projection.indexed_fields,
|
|
381
|
+
query_hints: projection.query_hints,
|
|
82
382
|
};
|
|
83
383
|
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
result.stats = await store.getVaultStats();
|
|
384
|
+
if (projection.stats) {
|
|
385
|
+
result.stats = projection.stats;
|
|
87
386
|
}
|
|
88
387
|
|
|
89
388
|
return result;
|
package/src/module-config.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
84
84
|
export function buildConfigValues(
|
|
85
85
|
vaultConfig: VaultConfig,
|
|
86
86
|
globalConfig: GlobalConfig,
|
|
87
|
-
env: { SCRIBE_URL?: string } = process.env,
|
|
87
|
+
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
88
88
|
): Record<string, unknown> {
|
|
89
89
|
return {
|
|
90
90
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|