@gmickel/gno 1.27.1 → 1.29.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/README.md +46 -20
- package/assets/skill/SKILL.md +59 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +6 -6
- package/package.json +2 -2
- package/spec/cli.md +82 -5
- package/spec/db/schema.sql +31 -0
- package/spec/mcp.md +71 -0
- package/spec/output-schemas/ask.schema.json +156 -1
- package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
- package/spec/output-schemas/collection-egress-check.schema.json +91 -0
- package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
- package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
- package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
- package/spec/output-schemas/egress-audit-management.schema.json +88 -0
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-http-error.schema.json +113 -2
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/publish-artifact.schema.json +32 -0
- package/spec/output-schemas/record-import.schema.json +223 -0
- package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
- package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
- package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +155 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/app/context-runtime-contract.ts +16 -7
- package/src/cli/commands/ask.ts +7 -0
- package/src/cli/commands/collection/index.ts +5 -0
- package/src/cli/commands/collection/policy.ts +126 -0
- package/src/cli/commands/context-build.ts +5 -0
- package/src/cli/commands/daemon.ts +45 -3
- package/src/cli/commands/egress-audit.ts +68 -0
- package/src/cli/commands/embed.ts +2 -0
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/query.ts +12 -0
- package/src/cli/commands/replay.ts +13 -3
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/commands/vsearch.ts +3 -1
- package/src/cli/program.ts +127 -0
- package/src/config/index.ts +9 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +66 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/browser-clip.ts +16 -1
- package/src/core/collection-egress-policy-projection.ts +28 -0
- package/src/core/collection-egress-policy-service.ts +443 -0
- package/src/core/collection-egress-policy-validation.ts +242 -0
- package/src/core/config-mutation.ts +31 -19
- package/src/core/context-capsule-schema.ts +198 -1
- package/src/core/context-capsule-validation.ts +4 -4
- package/src/core/context-capsule-verification.ts +5 -1
- package/src/core/context-capsule.ts +177 -112
- package/src/core/context-evidence.ts +93 -15
- package/src/core/destination-classifier.ts +402 -0
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/egress-audit.ts +239 -0
- package/src/core/egress-authorization.ts +50 -0
- package/src/core/egress-enforcement.ts +264 -0
- package/src/core/egress-policy.ts +440 -0
- package/src/core/egress-provenance.ts +336 -0
- package/src/core/job-manager.ts +37 -0
- package/src/core/network-boundary-inventory.ts +261 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/core/retrieval-qrels.ts +6 -0
- package/src/core/retrieval-replay.ts +18 -4
- package/src/core/retrieval-trace-export.ts +23 -4
- package/src/core/retrieval-trace-management-helpers.ts +1 -0
- package/src/core/retrieval-trace-management-types.ts +3 -0
- package/src/core/retrieval-trace-management.ts +9 -1
- package/src/core/retrieval-trace-session.ts +94 -0
- package/src/core/retrieval-trace.ts +22 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +448 -0
- package/src/ingestion/record-container.ts +704 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +243 -36
- package/src/ingestion/types.ts +71 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/llm/errors.ts +10 -0
- package/src/llm/http-inference.ts +175 -0
- package/src/llm/http-policy.ts +537 -0
- package/src/llm/httpEmbedding.ts +47 -28
- package/src/llm/httpGeneration.ts +31 -18
- package/src/llm/httpRerank.ts +30 -18
- package/src/llm/index.ts +1 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
- package/src/llm/pinned-http-connection.ts +177 -0
- package/src/mcp/context.ts +53 -1
- package/src/mcp/http-egress.ts +220 -0
- package/src/mcp/http-security.ts +32 -21
- package/src/mcp/http-session.ts +13 -2
- package/src/mcp/http-transport.ts +96 -6
- package/src/mcp/sync-egress.ts +24 -0
- package/src/mcp/tools/add-collection.ts +4 -0
- package/src/mcp/tools/ask.ts +1 -1
- package/src/mcp/tools/context.ts +9 -2
- package/src/mcp/tools/egress.ts +247 -0
- package/src/mcp/tools/embed.ts +1 -0
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/index-cmd.ts +7 -0
- package/src/mcp/tools/index.ts +98 -0
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/query.ts +10 -0
- package/src/mcp/tools/remove-collection.ts +4 -0
- package/src/mcp/tools/sync.ts +6 -0
- package/src/mcp/tools/trace.ts +7 -1
- package/src/mcp/tools/vsearch.ts +1 -0
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/egress-lineage.ts +124 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +21 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +23 -4
- package/src/pipeline/types.ts +6 -0
- package/src/pipeline/vsearch.ts +67 -28
- package/src/publish/artifact.ts +31 -3
- package/src/publish/export-service.ts +25 -0
- package/src/sdk/client.ts +119 -2
- package/src/sdk/documents.ts +13 -5
- package/src/sdk/embed.ts +1 -0
- package/src/sdk/types.ts +33 -0
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/clipper-capture.ts +5 -1
- package/src/serve/closed-json.ts +61 -0
- package/src/serve/config-sync.ts +4 -1
- package/src/serve/context.ts +1 -0
- package/src/serve/doc-events.ts +110 -39
- package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
- package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
- package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
- package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Collections.tsx +3 -0
- package/src/serve/resident-request.ts +80 -3
- package/src/serve/resident-runtime.ts +63 -1
- package/src/serve/routes/api.ts +237 -65
- package/src/serve/routes/clipper.ts +64 -1
- package/src/serve/routes/mcp.ts +6 -0
- package/src/serve/routes/traces.ts +27 -4
- package/src/serve/server.ts +118 -7
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/023-collection-egress-policy.ts +61 -0
- package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
- package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
- package/src/store/migrations/index.ts +8 -0
- package/src/store/retrieval-trace-codec.ts +15 -0
- package/src/store/sqlite/adapter.ts +284 -14
- package/src/store/sqlite/change-journal-store.ts +41 -2
- package/src/store/sqlite/egress-audit-store.ts +485 -0
- package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
- package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
- package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
- package/src/store/sqlite/retrieval-trace-store.ts +104 -5
- package/src/store/types.ts +166 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
- package/browser-extension/dist/chunk-vn5f663b.js +0 -50
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Per-request egress authorization for the resident HTTP MCP boundary. */
|
|
2
|
+
|
|
3
|
+
import type { Collection } from "../config/types";
|
|
4
|
+
import type {
|
|
5
|
+
EgressContentClass,
|
|
6
|
+
EgressDestinationZone,
|
|
7
|
+
} from "../core/egress-policy";
|
|
8
|
+
|
|
9
|
+
import { parseUri } from "../app/constants";
|
|
10
|
+
import {
|
|
11
|
+
collectionEgressStates,
|
|
12
|
+
EGRESS_DENIED_MESSAGE,
|
|
13
|
+
EgressDeniedError,
|
|
14
|
+
} from "../core/egress-enforcement";
|
|
15
|
+
import { evaluateEgressPolicy } from "../core/egress-policy";
|
|
16
|
+
|
|
17
|
+
export const MCP_HTTP_EGRESS_TOOLS = {
|
|
18
|
+
gno_add_collection: "metadata",
|
|
19
|
+
gno_ask: "capsule",
|
|
20
|
+
gno_backlinks: "metadata",
|
|
21
|
+
gno_capture: "metadata",
|
|
22
|
+
gno_changes: "metadata",
|
|
23
|
+
gno_clear_collection_embeddings: "metadata",
|
|
24
|
+
gno_context: "capsule",
|
|
25
|
+
gno_context_verify: "capsule",
|
|
26
|
+
gno_create_folder: "metadata",
|
|
27
|
+
gno_diff: "metadata",
|
|
28
|
+
gno_duplicate_note: "metadata",
|
|
29
|
+
gno_embed: "metadata",
|
|
30
|
+
gno_egress_audit_delete: "audit_log",
|
|
31
|
+
gno_egress_audit_list: "audit_log",
|
|
32
|
+
gno_egress_audit_purge: "audit_log",
|
|
33
|
+
gno_egress_audit_show: "audit_log",
|
|
34
|
+
gno_egress_audit_status: "audit_log",
|
|
35
|
+
gno_egress_check: "audit_log",
|
|
36
|
+
gno_egress_policy_get: "metadata",
|
|
37
|
+
gno_egress_policy_set: "metadata",
|
|
38
|
+
gno_get: "source",
|
|
39
|
+
gno_graph: "metadata",
|
|
40
|
+
gno_graph_neighbors: "metadata",
|
|
41
|
+
gno_graph_path: "metadata",
|
|
42
|
+
gno_graph_query: "metadata",
|
|
43
|
+
gno_impact: "metadata",
|
|
44
|
+
gno_index: "metadata",
|
|
45
|
+
gno_job_status: "metadata",
|
|
46
|
+
gno_links: "metadata",
|
|
47
|
+
gno_list_jobs: "metadata",
|
|
48
|
+
gno_list_tags: "metadata",
|
|
49
|
+
gno_move_note: "metadata",
|
|
50
|
+
gno_multi_get: "source",
|
|
51
|
+
gno_query: "snippet",
|
|
52
|
+
gno_query_diagnose: "metadata",
|
|
53
|
+
gno_remove_collection: "metadata",
|
|
54
|
+
gno_rename_note: "metadata",
|
|
55
|
+
gno_search: "snippet",
|
|
56
|
+
gno_similar: "snippet",
|
|
57
|
+
gno_status: "metadata",
|
|
58
|
+
gno_sync: "metadata",
|
|
59
|
+
gno_trace_delete: "retrieval_trace",
|
|
60
|
+
gno_trace_export: "retrieval_trace",
|
|
61
|
+
gno_trace_label: "retrieval_trace",
|
|
62
|
+
gno_trace_list: "retrieval_trace",
|
|
63
|
+
gno_trace_purge: "retrieval_trace",
|
|
64
|
+
gno_trace_show: "retrieval_trace",
|
|
65
|
+
gno_vsearch: "snippet",
|
|
66
|
+
} as const satisfies Record<string, EgressContentClass>;
|
|
67
|
+
|
|
68
|
+
export const MCP_HTTP_EGRESS_METHODS = {
|
|
69
|
+
"resources/list": "metadata",
|
|
70
|
+
"resources/read": "source",
|
|
71
|
+
"resources/templates/list": "metadata",
|
|
72
|
+
} as const satisfies Record<string, EgressContentClass>;
|
|
73
|
+
|
|
74
|
+
export interface HttpMcpEgressContext {
|
|
75
|
+
authenticated: boolean;
|
|
76
|
+
destinationZone: EgressDestinationZone;
|
|
77
|
+
operationAuthorized: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface JsonRpcMessage {
|
|
81
|
+
id?: unknown;
|
|
82
|
+
method?: unknown;
|
|
83
|
+
params?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class HttpMcpEgressDeniedError extends EgressDeniedError {
|
|
87
|
+
readonly requestId: unknown;
|
|
88
|
+
|
|
89
|
+
constructor(error: EgressDeniedError, requestId: unknown) {
|
|
90
|
+
super(error.decision);
|
|
91
|
+
this.name = "HttpMcpEgressDeniedError";
|
|
92
|
+
this.requestId = requestId;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SYSTEM_EGRESS_STATE = {
|
|
97
|
+
collection: "system",
|
|
98
|
+
policy: "local_only",
|
|
99
|
+
source: "config_default",
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
103
|
+
typeof value === "object" && value !== null
|
|
104
|
+
? (value as Record<string, unknown>)
|
|
105
|
+
: null;
|
|
106
|
+
|
|
107
|
+
const collectionFromRef = (value: unknown): string | null => {
|
|
108
|
+
if (typeof value !== "string") return null;
|
|
109
|
+
const parsed = parseUri(value);
|
|
110
|
+
if (parsed) return parsed.collection;
|
|
111
|
+
const slash = value.indexOf("/");
|
|
112
|
+
if (slash <= 0 || value.startsWith("#")) return null;
|
|
113
|
+
return value.slice(0, slash).trim().toLowerCase() || null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const requestedCollections = (
|
|
117
|
+
params: unknown,
|
|
118
|
+
collections: readonly Collection[]
|
|
119
|
+
): string[] => {
|
|
120
|
+
const record = asRecord(params);
|
|
121
|
+
const args = asRecord(record?.arguments ?? params);
|
|
122
|
+
if (!args) return collections.map(({ name }) => name);
|
|
123
|
+
|
|
124
|
+
const names = new Set<string>();
|
|
125
|
+
const direct = args.collection;
|
|
126
|
+
if (typeof direct === "string") names.add(direct.trim().toLowerCase());
|
|
127
|
+
for (const key of ["ref", "target", "from", "to", "root", "uri"]) {
|
|
128
|
+
const collection = collectionFromRef(args[key]);
|
|
129
|
+
if (collection) names.add(collection);
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(args.refs)) {
|
|
132
|
+
for (const ref of args.refs) {
|
|
133
|
+
const collection = collectionFromRef(ref);
|
|
134
|
+
if (collection) names.add(collection);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return names.size > 0 ? [...names] : collections.map(({ name }) => name);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const enforceMessage = (
|
|
141
|
+
message: JsonRpcMessage,
|
|
142
|
+
collections: readonly Collection[],
|
|
143
|
+
context: HttpMcpEgressContext
|
|
144
|
+
): void => {
|
|
145
|
+
let contentClass: EgressContentClass | undefined;
|
|
146
|
+
if (message.method === "tools/call") {
|
|
147
|
+
const params = asRecord(message.params);
|
|
148
|
+
const name = params?.name;
|
|
149
|
+
if (typeof name !== "string" || !(name in MCP_HTTP_EGRESS_TOOLS)) return;
|
|
150
|
+
// Trace export is authorized inside RetrievalTraceManagementService after
|
|
151
|
+
// exact trace IDs resolve to their immutable collection lineage.
|
|
152
|
+
if (name === "gno_trace_export") return;
|
|
153
|
+
contentClass =
|
|
154
|
+
MCP_HTTP_EGRESS_TOOLS[name as keyof typeof MCP_HTTP_EGRESS_TOOLS];
|
|
155
|
+
} else if (
|
|
156
|
+
typeof message.method === "string" &&
|
|
157
|
+
message.method in MCP_HTTP_EGRESS_METHODS
|
|
158
|
+
) {
|
|
159
|
+
contentClass =
|
|
160
|
+
MCP_HTTP_EGRESS_METHODS[
|
|
161
|
+
message.method as keyof typeof MCP_HTTP_EGRESS_METHODS
|
|
162
|
+
];
|
|
163
|
+
} else {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const names = requestedCollections(message.params, collections);
|
|
168
|
+
const scoped = collectionEgressStates(collections, names);
|
|
169
|
+
const decision = evaluateEgressPolicy({
|
|
170
|
+
collections: scoped.length > 0 ? scoped : [SYSTEM_EGRESS_STATE],
|
|
171
|
+
action: "serve",
|
|
172
|
+
destination: { zone: context.destinationZone },
|
|
173
|
+
caller: {
|
|
174
|
+
authenticated: context.authenticated,
|
|
175
|
+
operationAuthorized: context.operationAuthorized,
|
|
176
|
+
},
|
|
177
|
+
contentClass,
|
|
178
|
+
});
|
|
179
|
+
if (!decision.allowed) {
|
|
180
|
+
throw new HttpMcpEgressDeniedError(
|
|
181
|
+
new EgressDeniedError(decision),
|
|
182
|
+
message.id
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const enforceHttpMcpEgress = (
|
|
188
|
+
payload: unknown,
|
|
189
|
+
collections: readonly Collection[],
|
|
190
|
+
context: HttpMcpEgressContext
|
|
191
|
+
): void => {
|
|
192
|
+
const messages = Array.isArray(payload) ? payload : [payload];
|
|
193
|
+
for (const value of messages) {
|
|
194
|
+
const message = asRecord(value);
|
|
195
|
+
if (message) enforceMessage(message, collections, context);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const httpMcpEgressDeniedResponse = (
|
|
200
|
+
error: EgressDeniedError,
|
|
201
|
+
payload: unknown
|
|
202
|
+
): Response => {
|
|
203
|
+
const first = Array.isArray(payload) ? payload[0] : payload;
|
|
204
|
+
const id =
|
|
205
|
+
error instanceof HttpMcpEgressDeniedError
|
|
206
|
+
? (error.requestId ?? null)
|
|
207
|
+
: (asRecord(first)?.id ?? null);
|
|
208
|
+
return Response.json(
|
|
209
|
+
{
|
|
210
|
+
jsonrpc: "2.0",
|
|
211
|
+
error: {
|
|
212
|
+
code: -32_003,
|
|
213
|
+
message: EGRESS_DENIED_MESSAGE,
|
|
214
|
+
data: error.toJSON(),
|
|
215
|
+
},
|
|
216
|
+
id,
|
|
217
|
+
},
|
|
218
|
+
{ status: 403 }
|
|
219
|
+
);
|
|
220
|
+
};
|
package/src/mcp/http-security.ts
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
import { lstat, open } from "node:fs/promises";
|
|
5
5
|
|
|
6
6
|
import type { HttpGatewayConfig } from "../config/types";
|
|
7
|
+
import type { DestinationClassification } from "../core/destination-classifier";
|
|
7
8
|
|
|
8
9
|
import { expandPath } from "../config/paths";
|
|
10
|
+
import {
|
|
11
|
+
classifyBindDestination,
|
|
12
|
+
classifyDestination,
|
|
13
|
+
} from "../core/destination-classifier";
|
|
9
14
|
|
|
10
15
|
export const DEFAULT_HTTP_GATEWAY_HOST = "127.0.0.1";
|
|
11
16
|
export const DEFAULT_HTTP_GATEWAY_PORT = 3000;
|
|
@@ -54,7 +59,9 @@ export interface HttpGatewayOverrides {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export interface AuthorizedHttpMcpRequest {
|
|
62
|
+
authenticated: boolean;
|
|
57
63
|
identity: string;
|
|
64
|
+
peerClassification: DestinationClassification;
|
|
58
65
|
parsedBody?: unknown;
|
|
59
66
|
request: Request;
|
|
60
67
|
}
|
|
@@ -97,29 +104,21 @@ function securityError(status: 401 | 403 | 413 | 429 | 503): Response {
|
|
|
97
104
|
);
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
function isLoopbackAddress(address: string): boolean {
|
|
101
|
-
const normalized = address.toLowerCase();
|
|
102
|
-
if (normalized === "::1") return true;
|
|
103
|
-
const mapped = normalized.startsWith("::ffff:")
|
|
104
|
-
? normalized.slice("::ffff:".length)
|
|
105
|
-
: normalized;
|
|
106
|
-
const octets = mapped.split(".");
|
|
107
|
-
return (
|
|
108
|
-
octets.length === 4 &&
|
|
109
|
-
octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
|
|
110
|
-
Number(octets[0]) === 127 &&
|
|
111
|
-
octets.every((octet) => Number(octet) <= 255)
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
107
|
export function isHttpGatewayLoopbackBind(host: string): boolean {
|
|
116
|
-
return host
|
|
108
|
+
return classifyBindDestination(host).zone === "loopback";
|
|
117
109
|
}
|
|
118
110
|
|
|
119
111
|
function defaultAllowedHosts(host: string, port: number): string[] {
|
|
120
|
-
if (host
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
if (!isHttpGatewayLoopbackBind(host)) return [];
|
|
113
|
+
const normalized =
|
|
114
|
+
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
115
|
+
const rawAuthority = normalized.includes(":")
|
|
116
|
+
? `[${normalized}]:${port}`
|
|
117
|
+
: `${normalized}:${port}`;
|
|
118
|
+
const authority = new URL(`http://${rawAuthority}`).host;
|
|
119
|
+
return normalized === "127.0.0.1"
|
|
120
|
+
? [authority, `localhost:${port}`]
|
|
121
|
+
: [authority];
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
function defaultAllowedOrigins(host: string, port: number): string[] {
|
|
@@ -370,9 +369,13 @@ export class HttpMcpSecurity {
|
|
|
370
369
|
): Promise<HttpMcpAuthorizationResult> {
|
|
371
370
|
const peer = server.requestIP(request);
|
|
372
371
|
if (!peer) return { ok: false, response: securityError(403) };
|
|
372
|
+
const peerClassification = classifyDestination({
|
|
373
|
+
kind: "network",
|
|
374
|
+
hostname: peer.address,
|
|
375
|
+
});
|
|
373
376
|
if (
|
|
374
377
|
isHttpGatewayLoopbackBind(this.#config.host) &&
|
|
375
|
-
|
|
378
|
+
peerClassification.zone !== "loopback"
|
|
376
379
|
)
|
|
377
380
|
return { ok: false, response: securityError(403) };
|
|
378
381
|
|
|
@@ -397,6 +400,7 @@ export class HttpMcpSecurity {
|
|
|
397
400
|
this.#config.tokenFile !== undefined;
|
|
398
401
|
const tokenState = await this.#refreshTokenState();
|
|
399
402
|
let identity = "loopback";
|
|
403
|
+
let authenticated = false;
|
|
400
404
|
if (requiresAuth) {
|
|
401
405
|
if (!tokenState) return { ok: false, response: securityError(503) };
|
|
402
406
|
const authorization = request.headers.get("authorization");
|
|
@@ -406,6 +410,7 @@ export class HttpMcpSecurity {
|
|
|
406
410
|
if (!constantTimeEqual(presentedDigest, tokenState.digest))
|
|
407
411
|
return { ok: false, response: securityError(401) };
|
|
408
412
|
identity = tokenState.digest;
|
|
413
|
+
authenticated = true;
|
|
409
414
|
}
|
|
410
415
|
|
|
411
416
|
let parsedBody: unknown;
|
|
@@ -435,7 +440,13 @@ export class HttpMcpSecurity {
|
|
|
435
440
|
|
|
436
441
|
return {
|
|
437
442
|
ok: true,
|
|
438
|
-
value: {
|
|
443
|
+
value: {
|
|
444
|
+
authenticated,
|
|
445
|
+
identity,
|
|
446
|
+
peerClassification,
|
|
447
|
+
parsedBody,
|
|
448
|
+
request: sanitizedRequest,
|
|
449
|
+
},
|
|
439
450
|
};
|
|
440
451
|
}
|
|
441
452
|
|
package/src/mcp/http-session.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface PendingHttpMcpSession {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
interface OwnedHttpMcpSession extends HttpMcpSession {
|
|
46
|
+
invalidated: boolean;
|
|
46
47
|
releaseRuntimeSession(): void;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -133,6 +134,10 @@ export class HttpMcpSessionStore {
|
|
|
133
134
|
finishRequest(session: HttpMcpSession): void {
|
|
134
135
|
session.activeRequests = Math.max(0, session.activeRequests - 1);
|
|
135
136
|
session.lastActivityAt = this.#now();
|
|
137
|
+
const owned = session as OwnedHttpMcpSession;
|
|
138
|
+
if (owned.invalidated && owned.activeRequests === 0) {
|
|
139
|
+
void owned.server.close().catch(() => undefined);
|
|
140
|
+
}
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
async createPendingSession(
|
|
@@ -168,6 +173,7 @@ export class HttpMcpSessionStore {
|
|
|
168
173
|
transport,
|
|
169
174
|
lastActivityAt: this.#now(),
|
|
170
175
|
activeRequests: 0,
|
|
176
|
+
invalidated: false,
|
|
171
177
|
releaseRuntimeSession,
|
|
172
178
|
};
|
|
173
179
|
this.#sessions.set(sessionId, host.session);
|
|
@@ -250,14 +256,19 @@ export class HttpMcpSessionStore {
|
|
|
250
256
|
async closeSessions(): Promise<void> {
|
|
251
257
|
const sessions = [...this.#sessions.values()];
|
|
252
258
|
this.#sessions.clear();
|
|
253
|
-
for (const session of sessions)
|
|
259
|
+
for (const session of sessions) {
|
|
260
|
+
session.invalidated = true;
|
|
261
|
+
session.releaseRuntimeSession();
|
|
262
|
+
}
|
|
254
263
|
|
|
255
264
|
const pending = [...this.#pending];
|
|
256
265
|
this.#pending.clear();
|
|
257
266
|
for (const host of pending) host.discarded = true;
|
|
258
267
|
|
|
259
268
|
await Promise.allSettled([
|
|
260
|
-
...sessions
|
|
269
|
+
...sessions
|
|
270
|
+
.filter((session) => session.activeRequests === 0)
|
|
271
|
+
.map((session) => session.server.close()),
|
|
261
272
|
...pending.map((host) => host.server.close()),
|
|
262
273
|
]);
|
|
263
274
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
|
|
5
|
+
import type { DestinationClassification } from "../core/destination-classifier";
|
|
5
6
|
import type { ResidentRequestHandle } from "../serve/resident-runtime";
|
|
6
7
|
import type {
|
|
7
8
|
HttpMcpSession,
|
|
@@ -10,6 +11,11 @@ import type {
|
|
|
10
11
|
PendingHttpMcpSession,
|
|
11
12
|
} from "./http-session";
|
|
12
13
|
|
|
14
|
+
import { EgressDeniedError } from "../core/egress-enforcement";
|
|
15
|
+
import {
|
|
16
|
+
enforceHttpMcpEgress,
|
|
17
|
+
httpMcpEgressDeniedResponse,
|
|
18
|
+
} from "./http-egress";
|
|
13
19
|
import { HttpMcpSessionStore } from "./http-session";
|
|
14
20
|
import { MCP_WRITE_TOOL_NAMES } from "./tools/index";
|
|
15
21
|
|
|
@@ -17,8 +23,12 @@ const DEFAULT_MAX_CONCURRENT_REQUESTS = 64;
|
|
|
17
23
|
const DEFAULT_MAX_QUEUED_REQUESTS = 0;
|
|
18
24
|
const MCP_HTTP_METHODS = new Set(["DELETE", "GET", "POST"]);
|
|
19
25
|
const MCP_SESSION_HEADER = "mcp-session-id";
|
|
26
|
+
const POLICY_CHANGED_SSE = new TextEncoder().encode(
|
|
27
|
+
'event: message\ndata: {"jsonrpc":"2.0","error":{"code":-32000,"message":"EGRESS_POLICY_CHANGED: Collection policy changed; retry"},"id":null}\n\n'
|
|
28
|
+
);
|
|
20
29
|
|
|
21
30
|
export interface HttpMcpTransportRuntime extends HttpMcpSessionRuntime {
|
|
31
|
+
readonly authorizationEpoch?: string;
|
|
22
32
|
readonly isShuttingDown: boolean;
|
|
23
33
|
admitRequest(signal?: AbortSignal): ResidentRequestHandle | null;
|
|
24
34
|
}
|
|
@@ -30,8 +40,10 @@ export interface HttpMcpTransportOptions extends HttpMcpSessionStoreOptions {
|
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export interface HttpMcpRequestContext {
|
|
43
|
+
authenticated?: boolean;
|
|
33
44
|
identity: string;
|
|
34
45
|
parsedBody?: unknown;
|
|
46
|
+
peerClassification?: DestinationClassification;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export interface HttpMcpTransportStatus {
|
|
@@ -101,7 +113,8 @@ function validateInitializeHeaders(request: Request): Response | undefined {
|
|
|
101
113
|
|
|
102
114
|
function wrapStreamingResponse(
|
|
103
115
|
response: Response,
|
|
104
|
-
finish: () => void
|
|
116
|
+
finish: () => void,
|
|
117
|
+
isAuthorizationEpochCurrent: () => boolean
|
|
105
118
|
): Response {
|
|
106
119
|
if (!response.body) {
|
|
107
120
|
finish();
|
|
@@ -117,7 +130,21 @@ function wrapStreamingResponse(
|
|
|
117
130
|
const body = new ReadableStream<Uint8Array>({
|
|
118
131
|
async pull(controller) {
|
|
119
132
|
try {
|
|
133
|
+
if (!isAuthorizationEpochCurrent()) {
|
|
134
|
+
await reader.cancel("EGRESS_POLICY_CHANGED");
|
|
135
|
+
controller.enqueue(POLICY_CHANGED_SSE);
|
|
136
|
+
controller.close();
|
|
137
|
+
finishOnce();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
120
140
|
const result = await reader.read();
|
|
141
|
+
if (!isAuthorizationEpochCurrent()) {
|
|
142
|
+
await reader.cancel("EGRESS_POLICY_CHANGED");
|
|
143
|
+
controller.enqueue(POLICY_CHANGED_SSE);
|
|
144
|
+
controller.close();
|
|
145
|
+
finishOnce();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
121
148
|
if (result.done) {
|
|
122
149
|
controller.close();
|
|
123
150
|
finishOnce();
|
|
@@ -137,6 +164,13 @@ function wrapStreamingResponse(
|
|
|
137
164
|
return new Response(body, response);
|
|
138
165
|
}
|
|
139
166
|
|
|
167
|
+
const policyChangedResponse = (): Response =>
|
|
168
|
+
jsonRpcError(
|
|
169
|
+
409,
|
|
170
|
+
-32_000,
|
|
171
|
+
"EGRESS_POLICY_CHANGED: Collection policy changed; retry"
|
|
172
|
+
);
|
|
173
|
+
|
|
140
174
|
/** Stateful session gateway used by the production `/mcp` route. */
|
|
141
175
|
export class HttpMcpTransport {
|
|
142
176
|
readonly #runtime: HttpMcpTransportRuntime;
|
|
@@ -212,6 +246,15 @@ export class HttpMcpTransport {
|
|
|
212
246
|
let session: HttpMcpSession | undefined;
|
|
213
247
|
let pending: PendingHttpMcpSession | undefined;
|
|
214
248
|
let finished = false;
|
|
249
|
+
const authorizationEpoch = {
|
|
250
|
+
value:
|
|
251
|
+
admission.authorizationEpoch ??
|
|
252
|
+
this.#runtime.authorizationEpoch ??
|
|
253
|
+
"egress-epoch-unavailable",
|
|
254
|
+
};
|
|
255
|
+
const isAuthorizationEpochCurrent = (): boolean =>
|
|
256
|
+
this.#runtime.authorizationEpoch === undefined ||
|
|
257
|
+
authorizationEpoch.value === this.#runtime.authorizationEpoch;
|
|
215
258
|
const finish = (): void => {
|
|
216
259
|
if (finished) return;
|
|
217
260
|
finished = true;
|
|
@@ -293,10 +336,53 @@ export class HttpMcpTransport {
|
|
|
293
336
|
finish();
|
|
294
337
|
return jsonRpcError(403, -32_000, "Forbidden");
|
|
295
338
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
try {
|
|
340
|
+
enforceHttpMcpEgress(
|
|
341
|
+
requestBody,
|
|
342
|
+
this.#runtime.mcpContext.collections,
|
|
343
|
+
{
|
|
344
|
+
authenticated: context.authenticated ?? false,
|
|
345
|
+
destinationZone:
|
|
346
|
+
context.peerClassification?.zone ??
|
|
347
|
+
(context.identity === "loopback" ? "loopback" : "remote"),
|
|
348
|
+
operationAuthorized: true,
|
|
349
|
+
}
|
|
350
|
+
);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof EgressDeniedError) {
|
|
353
|
+
await pending?.discard();
|
|
354
|
+
finish();
|
|
355
|
+
return httpMcpEgressDeniedResponse(error, requestBody);
|
|
356
|
+
}
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
const handle = () =>
|
|
360
|
+
transport.handleRequest(
|
|
361
|
+
request,
|
|
362
|
+
requestBody === undefined ? undefined : { parsedBody: requestBody }
|
|
363
|
+
);
|
|
364
|
+
const destinationZone =
|
|
365
|
+
context.peerClassification?.zone ??
|
|
366
|
+
(context.identity === "loopback" ? "loopback" : "remote");
|
|
367
|
+
const response = this.#runtime.mcpContext.runWithEgressContext
|
|
368
|
+
? await this.#runtime.mcpContext.runWithEgressContext(
|
|
369
|
+
{
|
|
370
|
+
destinationZone,
|
|
371
|
+
caller: {
|
|
372
|
+
authenticated: context.authenticated ?? false,
|
|
373
|
+
operationAuthorized: true,
|
|
374
|
+
},
|
|
375
|
+
authorizationEpoch,
|
|
376
|
+
},
|
|
377
|
+
handle
|
|
378
|
+
)
|
|
379
|
+
: await handle();
|
|
380
|
+
|
|
381
|
+
if (!isAuthorizationEpochCurrent()) {
|
|
382
|
+
await pending?.discard();
|
|
383
|
+
finish();
|
|
384
|
+
return policyChangedResponse();
|
|
385
|
+
}
|
|
300
386
|
|
|
301
387
|
if (pending) {
|
|
302
388
|
session = pending.session;
|
|
@@ -305,7 +391,11 @@ export class HttpMcpTransport {
|
|
|
305
391
|
}
|
|
306
392
|
|
|
307
393
|
if (response.headers.get("content-type")?.includes("text/event-stream")) {
|
|
308
|
-
return wrapStreamingResponse(
|
|
394
|
+
return wrapStreamingResponse(
|
|
395
|
+
response,
|
|
396
|
+
finish,
|
|
397
|
+
isAuthorizationEpochCurrent
|
|
398
|
+
);
|
|
309
399
|
}
|
|
310
400
|
finish();
|
|
311
401
|
return response;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Fail-closed guard for sync modes that can spawn network-capable commands. */
|
|
2
|
+
|
|
3
|
+
import type { ToolContext } from "./context";
|
|
4
|
+
|
|
5
|
+
import { enforceCollectionEgress } from "../core/egress-enforcement";
|
|
6
|
+
|
|
7
|
+
export const enforceSyncCommandEgress = (
|
|
8
|
+
ctx: ToolContext,
|
|
9
|
+
input: {
|
|
10
|
+
collectionNames?: readonly string[];
|
|
11
|
+
gitPull?: boolean;
|
|
12
|
+
runUpdateCmd?: boolean;
|
|
13
|
+
}
|
|
14
|
+
): void => {
|
|
15
|
+
if (!(input.gitPull || input.runUpdateCmd)) return;
|
|
16
|
+
enforceCollectionEgress({
|
|
17
|
+
collections: ctx.collections,
|
|
18
|
+
collectionNames: input.collectionNames,
|
|
19
|
+
action: "export",
|
|
20
|
+
destinationZone: "remote",
|
|
21
|
+
caller: { authenticated: true, operationAuthorized: ctx.enableWrite },
|
|
22
|
+
contentClass: "source",
|
|
23
|
+
});
|
|
24
|
+
};
|
|
@@ -140,6 +140,10 @@ export function handleAddCollection(
|
|
|
140
140
|
if (!collection) {
|
|
141
141
|
throw new Error("RUNTIME: Collection missing after add");
|
|
142
142
|
}
|
|
143
|
+
const invalidation = await ctx.invalidateEgressPolicy?.();
|
|
144
|
+
if (invalidation) {
|
|
145
|
+
ctx.advanceRequestAuthorizationEpoch?.(invalidation.policyEpoch);
|
|
146
|
+
}
|
|
143
147
|
|
|
144
148
|
const jobId = await ctx.jobManager.startJobWithLock(
|
|
145
149
|
"add",
|
package/src/mcp/tools/ask.ts
CHANGED
|
@@ -202,7 +202,7 @@ export const handleAsk = (
|
|
|
202
202
|
traceSession = started.value ?? undefined;
|
|
203
203
|
modelPorts = await createMcpModelPorts(
|
|
204
204
|
context,
|
|
205
|
-
args.collection,
|
|
205
|
+
args.collection ? [args.collection] : undefined,
|
|
206
206
|
dependencies.modelPortFactory,
|
|
207
207
|
{ generation: true }
|
|
208
208
|
);
|
package/src/mcp/tools/context.ts
CHANGED
|
@@ -119,7 +119,7 @@ export const disposeContextModelOwners = async (
|
|
|
119
119
|
|
|
120
120
|
export const createMcpModelPorts = async (
|
|
121
121
|
context: ToolContext,
|
|
122
|
-
|
|
122
|
+
collections?: readonly string[],
|
|
123
123
|
factoryOverride?: McpModelPortFactory,
|
|
124
124
|
options: { generation?: boolean } = {}
|
|
125
125
|
): Promise<McpModelPorts> => {
|
|
@@ -128,6 +128,10 @@ export const createMcpModelPorts = async (
|
|
|
128
128
|
const lease = factory.acquireModelLease?.();
|
|
129
129
|
const policy = resolveDownloadPolicy(process.env, {});
|
|
130
130
|
const progress = createNonTtyProgressRenderer();
|
|
131
|
+
const egressCollections = collections?.length
|
|
132
|
+
? collections
|
|
133
|
+
: ("all" as const);
|
|
134
|
+
const collection = collections?.length === 1 ? collections[0] : undefined;
|
|
131
135
|
const embedUri = resolveModelUri(
|
|
132
136
|
context.config,
|
|
133
137
|
"embed",
|
|
@@ -141,6 +145,7 @@ export const createMcpModelPorts = async (
|
|
|
141
145
|
let vectorIndex: VectorIndexPort | null = null;
|
|
142
146
|
try {
|
|
143
147
|
const embedResult = await factory.createEmbeddingPort(embedUri, {
|
|
148
|
+
egressCollections,
|
|
144
149
|
policy,
|
|
145
150
|
onProgress: (value) => progress("embed", value),
|
|
146
151
|
});
|
|
@@ -160,6 +165,7 @@ export const createMcpModelPorts = async (
|
|
|
160
165
|
const rerankResult = await factory.createRerankPort(
|
|
161
166
|
resolveModelUri(context.config, "rerank", undefined, collection),
|
|
162
167
|
{
|
|
168
|
+
egressCollections,
|
|
163
169
|
policy,
|
|
164
170
|
onProgress: (value) => progress("rerank", value),
|
|
165
171
|
}
|
|
@@ -169,6 +175,7 @@ export const createMcpModelPorts = async (
|
|
|
169
175
|
const genResult = await factory.createGenerationPort(
|
|
170
176
|
resolveModelUri(context.config, "gen", undefined, collection),
|
|
171
177
|
{
|
|
178
|
+
egressCollections,
|
|
172
179
|
policy,
|
|
173
180
|
onProgress: (value) => progress("gen", value),
|
|
174
181
|
}
|
|
@@ -269,7 +276,7 @@ export const handleContext = (
|
|
|
269
276
|
if (!traceStart.ok) throw new Error(traceStart.error.message);
|
|
270
277
|
traceSession = traceStart.value ?? undefined;
|
|
271
278
|
modelPorts = useModels
|
|
272
|
-
? await createMcpModelPorts(context,
|
|
279
|
+
? await createMcpModelPorts(context, parsed.input.collections)
|
|
273
280
|
: null;
|
|
274
281
|
const capsule = await buildContextCapsule(parsed.input, {
|
|
275
282
|
store: context.store,
|