@gmickel/gno 1.12.3 → 1.13.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 +57 -30
- package/assets/skill/SKILL.md +6 -1
- package/assets/skill/cli-reference.md +16 -6
- package/assets/skill/mcp-reference.md +22 -3
- package/package.json +2 -1
- package/src/app/constants.ts +43 -10
- package/src/app/index-name.ts +127 -0
- package/src/cli/commands/doctor-activation.ts +151 -0
- package/src/cli/commands/doctor.ts +41 -16
- package/src/cli/commands/get.ts +18 -0
- package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
- package/src/cli/commands/mcp/config-discovery.ts +42 -0
- package/src/cli/commands/mcp/config-editors.ts +432 -0
- package/src/cli/commands/mcp/config.ts +63 -160
- package/src/cli/commands/mcp/install.ts +75 -37
- package/src/cli/commands/mcp/paths.ts +141 -136
- package/src/cli/commands/mcp/server-entry.ts +66 -0
- package/src/cli/commands/mcp/status.ts +189 -57
- package/src/cli/commands/mcp/target-display.ts +30 -0
- package/src/cli/commands/mcp/uninstall.ts +29 -31
- package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
- package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
- package/src/cli/commands/multi-get.ts +31 -6
- package/src/cli/commands/status.ts +107 -11
- package/src/cli/program.ts +66 -20
- package/src/core/activation-connector-health.ts +19 -0
- package/src/core/activation-probe-plan.ts +321 -0
- package/src/core/activation-probe.ts +138 -0
- package/src/core/activation-receipt-store.ts +39 -0
- package/src/core/activation-status.ts +513 -0
- package/src/core/activation-verifier.ts +416 -0
- package/src/core/connector-environment.ts +68 -0
- package/src/core/connector-policy.ts +233 -0
- package/src/core/connector-verification-target.ts +150 -0
- package/src/core/connector-verifier.ts +497 -0
- package/src/core/context-resolver.ts +285 -0
- package/src/core/indexed-reference.ts +33 -8
- package/src/core/runtime-entrypoint.ts +24 -0
- package/src/mcp/activation-verification-mode.ts +4 -0
- package/src/mcp/server.ts +9 -2
- package/src/mcp/tools/index.ts +3 -3
- package/src/pipeline/answer-prompt.ts +80 -0
- package/src/pipeline/answer.ts +12 -26
- package/src/pipeline/hybrid.ts +2 -0
- package/src/pipeline/result-context.ts +51 -0
- package/src/pipeline/search.ts +5 -1
- package/src/pipeline/vsearch.ts +2 -0
- package/src/sdk/client.ts +7 -0
- package/src/sdk/types.ts +1 -0
- package/src/serve/activation-health.ts +91 -0
- package/src/serve/background-runtime.ts +11 -1
- package/src/serve/connectors.ts +164 -19
- package/src/serve/public/components/BootstrapStatus.tsx +94 -1
- package/src/serve/public/components/FirstRunWizard.tsx +13 -51
- package/src/serve/public/components/HealthCenter.tsx +8 -2
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Connectors.tsx +216 -55
- package/src/serve/public/pages/Dashboard.tsx +1 -0
- package/src/serve/routes/api.ts +152 -8
- package/src/serve/server.ts +44 -9
- package/src/serve/status-model.ts +4 -0
- package/src/serve/status.ts +79 -35
- package/src/store/activation-receipts.ts +390 -0
- package/src/store/index.ts +8 -0
- package/src/store/migrations/012-activation-receipts.ts +38 -0
- package/src/store/migrations/013-fts-sync-marker.ts +39 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +320 -53
- package/src/store/types.ts +124 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, local-only retrieval activation verification.
|
|
3
|
+
*
|
|
4
|
+
* Probe terms exist only in memory. Persisted receipts contain a SHA-256 probe
|
|
5
|
+
* hash plus exact result identity, never the query, snippet, or passage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ActivationStageReceipt,
|
|
10
|
+
ActivationVerificationCode,
|
|
11
|
+
ActivationVerificationReceipt,
|
|
12
|
+
StorePort,
|
|
13
|
+
StoreResult,
|
|
14
|
+
} from "../store/types";
|
|
15
|
+
import type { EphemeralActivationProbePlan } from "./activation-probe-plan";
|
|
16
|
+
|
|
17
|
+
import { err, ok } from "../store/types";
|
|
18
|
+
import {
|
|
19
|
+
extractActivationProbeTerms,
|
|
20
|
+
fingerprintActivationIndex,
|
|
21
|
+
} from "./activation-probe";
|
|
22
|
+
import {
|
|
23
|
+
createEphemeralActivationProbePlan,
|
|
24
|
+
findEphemeralActivationProbeMatch,
|
|
25
|
+
populateEphemeralActivationProbePlan,
|
|
26
|
+
revalidateEphemeralActivationProbePlan,
|
|
27
|
+
} from "./activation-probe-plan";
|
|
28
|
+
import { persistActivationReceiptForKnownCollection } from "./activation-receipt-store";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
extractActivationProbeTerms,
|
|
32
|
+
fingerprintActivationIndex,
|
|
33
|
+
} from "./activation-probe";
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
ActivationStageName,
|
|
37
|
+
ActivationStageReceipt,
|
|
38
|
+
ActivationStageStatus,
|
|
39
|
+
ActivationVerificationCode,
|
|
40
|
+
ActivationVerificationReceipt,
|
|
41
|
+
} from "../store/types";
|
|
42
|
+
|
|
43
|
+
const RECEIPT_SCHEMA_VERSION = "1.0" as const;
|
|
44
|
+
const REUSABLE_NEGATIVE_CODES = new Set<ActivationVerificationCode>([
|
|
45
|
+
"no_documents",
|
|
46
|
+
"no_probe_term",
|
|
47
|
+
"index_out_of_sync",
|
|
48
|
+
]);
|
|
49
|
+
const MAX_VERIFICATION_ATTEMPTS = 2;
|
|
50
|
+
const CONCURRENT_INDEX_CHANGE_MESSAGE =
|
|
51
|
+
"Activation index changed repeatedly during verification; retry";
|
|
52
|
+
export interface ActivationVerifierOptions {
|
|
53
|
+
/** Re-run proof instead of reusing a current fingerprint-matched receipt. */
|
|
54
|
+
force?: boolean;
|
|
55
|
+
now?: () => Date;
|
|
56
|
+
monotonicNow?: () => number;
|
|
57
|
+
/** Precomputed, in-memory plan used to fingerprint-scope coalesced callers. */
|
|
58
|
+
plan?: EphemeralActivationProbePlan;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function elapsedMs(startedAt: number, monotonicNow: () => number): number {
|
|
62
|
+
return Math.max(0, Math.round(monotonicNow() - startedAt));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function completedStage(
|
|
66
|
+
status: "passed" | "failed" | "skipped",
|
|
67
|
+
startedAt: string | null,
|
|
68
|
+
completedAt: string,
|
|
69
|
+
latencyMs: number | null,
|
|
70
|
+
code?: ActivationVerificationCode
|
|
71
|
+
): ActivationStageReceipt {
|
|
72
|
+
return {
|
|
73
|
+
status,
|
|
74
|
+
startedAt,
|
|
75
|
+
completedAt,
|
|
76
|
+
latencyMs,
|
|
77
|
+
...(code ? { code } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pendingStage(
|
|
82
|
+
status: "pending" | "skipped",
|
|
83
|
+
code: ActivationVerificationCode
|
|
84
|
+
): ActivationStageReceipt {
|
|
85
|
+
return {
|
|
86
|
+
status,
|
|
87
|
+
startedAt: null,
|
|
88
|
+
completedAt: null,
|
|
89
|
+
latencyMs: null,
|
|
90
|
+
code,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isReusableLexicalReceipt(
|
|
95
|
+
receipt: ActivationVerificationReceipt
|
|
96
|
+
): boolean {
|
|
97
|
+
if (receipt.ready) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const code = receipt.stages.index.code ?? receipt.stages.lexical.code;
|
|
101
|
+
return code !== undefined && REUSABLE_NEGATIVE_CODES.has(code);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildReceipt(input: {
|
|
105
|
+
collection: string;
|
|
106
|
+
fingerprint: string;
|
|
107
|
+
generatedAt: string;
|
|
108
|
+
index: ActivationStageReceipt;
|
|
109
|
+
lexical: ActivationStageReceipt;
|
|
110
|
+
probeHash?: string;
|
|
111
|
+
result?: { uri: string; sourceHash: string };
|
|
112
|
+
}): ActivationVerificationReceipt {
|
|
113
|
+
const ready =
|
|
114
|
+
input.index.status === "passed" && input.lexical.status === "passed";
|
|
115
|
+
return {
|
|
116
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
117
|
+
collection: input.collection,
|
|
118
|
+
fingerprint: input.fingerprint,
|
|
119
|
+
ready,
|
|
120
|
+
generatedAt: input.generatedAt,
|
|
121
|
+
stages: {
|
|
122
|
+
index: input.index,
|
|
123
|
+
lexical: input.lexical,
|
|
124
|
+
semantic: pendingStage("pending", "semantic_not_checked"),
|
|
125
|
+
connector: pendingStage("skipped", "connector_not_requested"),
|
|
126
|
+
},
|
|
127
|
+
evidence: {
|
|
128
|
+
...(input.probeHash ? { probeHash: input.probeHash } : {}),
|
|
129
|
+
...(input.result
|
|
130
|
+
? {
|
|
131
|
+
resultUri: input.result.uri,
|
|
132
|
+
resultSourceHash: input.result.sourceHash,
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function persistReceipt(
|
|
140
|
+
store: StorePort,
|
|
141
|
+
receipt: ActivationVerificationReceipt
|
|
142
|
+
): Promise<StoreResult<ActivationVerificationReceipt>> {
|
|
143
|
+
return persistActivationReceiptForKnownCollection(store, receipt);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface ActivationVerificationAttempt {
|
|
147
|
+
receipt: ActivationVerificationReceipt;
|
|
148
|
+
cached: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function verifyLexicalActivationAttempt(
|
|
152
|
+
store: StorePort,
|
|
153
|
+
collection: string,
|
|
154
|
+
plan: EphemeralActivationProbePlan,
|
|
155
|
+
options: ActivationVerifierOptions
|
|
156
|
+
): Promise<StoreResult<ActivationVerificationAttempt>> {
|
|
157
|
+
const now = options.now ?? (() => new Date());
|
|
158
|
+
const monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
159
|
+
const indexStartedAt = now().toISOString();
|
|
160
|
+
const indexStartedClock = monotonicNow();
|
|
161
|
+
const { activeDocuments, fingerprint } = plan;
|
|
162
|
+
|
|
163
|
+
if (!options.force) {
|
|
164
|
+
const current = await store.getActivationReceipt(collection, fingerprint);
|
|
165
|
+
if (!current.ok) {
|
|
166
|
+
return err(
|
|
167
|
+
current.error.code,
|
|
168
|
+
current.error.message,
|
|
169
|
+
current.error.cause
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
// Deterministic negatives are stable for this exact fingerprint. Query and
|
|
173
|
+
// result mismatches remain recoverable and are retried without a TTL.
|
|
174
|
+
if (current.value && isReusableLexicalReceipt(current.value)) {
|
|
175
|
+
return ok({ receipt: current.value, cached: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const indexCompletedAt = now().toISOString();
|
|
180
|
+
if (activeDocuments.length === 0) {
|
|
181
|
+
return ok({
|
|
182
|
+
cached: false,
|
|
183
|
+
receipt: buildReceipt({
|
|
184
|
+
collection,
|
|
185
|
+
fingerprint,
|
|
186
|
+
generatedAt: indexCompletedAt,
|
|
187
|
+
index: completedStage(
|
|
188
|
+
"failed",
|
|
189
|
+
indexStartedAt,
|
|
190
|
+
indexCompletedAt,
|
|
191
|
+
elapsedMs(indexStartedClock, monotonicNow),
|
|
192
|
+
"no_documents"
|
|
193
|
+
),
|
|
194
|
+
lexical: completedStage(
|
|
195
|
+
"skipped",
|
|
196
|
+
null,
|
|
197
|
+
indexCompletedAt,
|
|
198
|
+
null,
|
|
199
|
+
"no_documents"
|
|
200
|
+
),
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!plan.identity.ftsSynchronized) {
|
|
206
|
+
return ok({
|
|
207
|
+
cached: false,
|
|
208
|
+
receipt: buildReceipt({
|
|
209
|
+
collection,
|
|
210
|
+
fingerprint,
|
|
211
|
+
generatedAt: indexCompletedAt,
|
|
212
|
+
index: completedStage(
|
|
213
|
+
"failed",
|
|
214
|
+
indexStartedAt,
|
|
215
|
+
indexCompletedAt,
|
|
216
|
+
elapsedMs(indexStartedClock, monotonicNow),
|
|
217
|
+
"index_out_of_sync"
|
|
218
|
+
),
|
|
219
|
+
lexical: completedStage(
|
|
220
|
+
"skipped",
|
|
221
|
+
null,
|
|
222
|
+
indexCompletedAt,
|
|
223
|
+
null,
|
|
224
|
+
"index_out_of_sync"
|
|
225
|
+
),
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const indexStage = completedStage(
|
|
231
|
+
"passed",
|
|
232
|
+
indexStartedAt,
|
|
233
|
+
indexCompletedAt,
|
|
234
|
+
elapsedMs(indexStartedClock, monotonicNow)
|
|
235
|
+
);
|
|
236
|
+
const lexicalStartedAt = now().toISOString();
|
|
237
|
+
const lexicalStartedClock = monotonicNow();
|
|
238
|
+
const populatedPlan = await populateEphemeralActivationProbePlan(store, plan);
|
|
239
|
+
const matchResult = populatedPlan.ok
|
|
240
|
+
? await findEphemeralActivationProbeMatch(store, populatedPlan.value)
|
|
241
|
+
: populatedPlan;
|
|
242
|
+
const completedAt = now().toISOString();
|
|
243
|
+
|
|
244
|
+
if (!matchResult.ok) {
|
|
245
|
+
return ok({
|
|
246
|
+
cached: false,
|
|
247
|
+
receipt: buildReceipt({
|
|
248
|
+
collection,
|
|
249
|
+
fingerprint,
|
|
250
|
+
generatedAt: completedAt,
|
|
251
|
+
index: indexStage,
|
|
252
|
+
lexical: completedStage(
|
|
253
|
+
"failed",
|
|
254
|
+
lexicalStartedAt,
|
|
255
|
+
completedAt,
|
|
256
|
+
elapsedMs(lexicalStartedClock, monotonicNow),
|
|
257
|
+
"index_query_failed"
|
|
258
|
+
),
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const match = matchResult.value;
|
|
264
|
+
if (match.kind === "no_probe_term") {
|
|
265
|
+
return ok({
|
|
266
|
+
cached: false,
|
|
267
|
+
receipt: buildReceipt({
|
|
268
|
+
collection,
|
|
269
|
+
fingerprint,
|
|
270
|
+
generatedAt: completedAt,
|
|
271
|
+
index: indexStage,
|
|
272
|
+
lexical: completedStage(
|
|
273
|
+
"failed",
|
|
274
|
+
lexicalStartedAt,
|
|
275
|
+
completedAt,
|
|
276
|
+
elapsedMs(lexicalStartedClock, monotonicNow),
|
|
277
|
+
"no_probe_term"
|
|
278
|
+
),
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (match.kind === "matched") {
|
|
284
|
+
return ok({
|
|
285
|
+
cached: false,
|
|
286
|
+
receipt: buildReceipt({
|
|
287
|
+
collection,
|
|
288
|
+
fingerprint,
|
|
289
|
+
generatedAt: completedAt,
|
|
290
|
+
index: indexStage,
|
|
291
|
+
lexical: completedStage(
|
|
292
|
+
"passed",
|
|
293
|
+
lexicalStartedAt,
|
|
294
|
+
completedAt,
|
|
295
|
+
elapsedMs(lexicalStartedClock, monotonicNow)
|
|
296
|
+
),
|
|
297
|
+
probeHash: match.value.probeHash,
|
|
298
|
+
result: {
|
|
299
|
+
uri: match.value.resultUri,
|
|
300
|
+
sourceHash: match.value.resultSourceHash,
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return ok({
|
|
307
|
+
cached: false,
|
|
308
|
+
receipt: buildReceipt({
|
|
309
|
+
collection,
|
|
310
|
+
fingerprint,
|
|
311
|
+
generatedAt: completedAt,
|
|
312
|
+
index: indexStage,
|
|
313
|
+
lexical: completedStage(
|
|
314
|
+
"failed",
|
|
315
|
+
lexicalStartedAt,
|
|
316
|
+
completedAt,
|
|
317
|
+
elapsedMs(lexicalStartedClock, monotonicNow),
|
|
318
|
+
"retrieval_mismatch"
|
|
319
|
+
),
|
|
320
|
+
probeHash: match.probeHash,
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function discardObsoleteLexicalReceipt(
|
|
326
|
+
store: StorePort,
|
|
327
|
+
collection: string,
|
|
328
|
+
currentFingerprint: string
|
|
329
|
+
): Promise<StoreResult<void>> {
|
|
330
|
+
const current = await store.getActivationReceipt(
|
|
331
|
+
collection,
|
|
332
|
+
currentFingerprint
|
|
333
|
+
);
|
|
334
|
+
return current.ok
|
|
335
|
+
? ok(undefined)
|
|
336
|
+
: err(current.error.code, current.error.message, current.error.cause);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Prove that one collection can retrieve a deterministic corpus-derived term.
|
|
341
|
+
* The verifier never loads embeddings or sends content outside the local store.
|
|
342
|
+
*/
|
|
343
|
+
export async function verifyLexicalActivation(
|
|
344
|
+
store: StorePort,
|
|
345
|
+
collection: string,
|
|
346
|
+
options: ActivationVerifierOptions = {}
|
|
347
|
+
): Promise<StoreResult<ActivationVerificationReceipt>> {
|
|
348
|
+
const planResult =
|
|
349
|
+
options.plan?.collection === collection
|
|
350
|
+
? ok(options.plan)
|
|
351
|
+
: await createEphemeralActivationProbePlan(store, collection, {
|
|
352
|
+
collectCandidates: false,
|
|
353
|
+
});
|
|
354
|
+
if (!planResult.ok) {
|
|
355
|
+
return planResult;
|
|
356
|
+
}
|
|
357
|
+
let plan = planResult.value;
|
|
358
|
+
|
|
359
|
+
for (let attempt = 0; attempt < MAX_VERIFICATION_ATTEMPTS; attempt += 1) {
|
|
360
|
+
const verification = await verifyLexicalActivationAttempt(
|
|
361
|
+
store,
|
|
362
|
+
collection,
|
|
363
|
+
plan,
|
|
364
|
+
options
|
|
365
|
+
);
|
|
366
|
+
if (!verification.ok) {
|
|
367
|
+
return verification;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const beforeAcceptance = await revalidateEphemeralActivationProbePlan(
|
|
371
|
+
store,
|
|
372
|
+
plan
|
|
373
|
+
);
|
|
374
|
+
if (!beforeAcceptance.ok) {
|
|
375
|
+
return beforeAcceptance;
|
|
376
|
+
}
|
|
377
|
+
if (!beforeAcceptance.value.stable) {
|
|
378
|
+
plan = beforeAcceptance.value.currentPlan;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (verification.value.cached) {
|
|
383
|
+
return ok(verification.value.receipt);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// A configured collection may be checked before its first sync has created
|
|
387
|
+
// the store row. The persistence helper returns the bounded receipt without
|
|
388
|
+
// violating the receipt table's collection foreign key.
|
|
389
|
+
const persisted = await persistReceipt(store, verification.value.receipt);
|
|
390
|
+
if (!persisted.ok) {
|
|
391
|
+
return persisted;
|
|
392
|
+
}
|
|
393
|
+
const afterPersistence = await revalidateEphemeralActivationProbePlan(
|
|
394
|
+
store,
|
|
395
|
+
plan
|
|
396
|
+
);
|
|
397
|
+
if (!afterPersistence.ok) {
|
|
398
|
+
return afterPersistence;
|
|
399
|
+
}
|
|
400
|
+
if (afterPersistence.value.stable) {
|
|
401
|
+
return persisted;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
plan = afterPersistence.value.currentPlan;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const cleanup = await discardObsoleteLexicalReceipt(
|
|
408
|
+
store,
|
|
409
|
+
collection,
|
|
410
|
+
plan.fingerprint
|
|
411
|
+
);
|
|
412
|
+
if (!cleanup.ok) {
|
|
413
|
+
return cleanup;
|
|
414
|
+
}
|
|
415
|
+
return err("INTERNAL", CONCURRENT_INDEX_CHANGE_MESSAGE);
|
|
416
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Audited environment contract for local MCP connector execution. */
|
|
2
|
+
|
|
3
|
+
// node:path has no Bun equivalent for portable absolute-path validation.
|
|
4
|
+
import { isAbsolute, normalize } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CONNECTOR_WORKSPACE_ENV_KEYS = [
|
|
7
|
+
"GNO_DATA_DIR",
|
|
8
|
+
"GNO_CACHE_DIR",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type ConnectorWorkspaceEnvKey =
|
|
12
|
+
(typeof CONNECTOR_WORKSPACE_ENV_KEYS)[number];
|
|
13
|
+
export type ConnectorWorkspaceEnvironment = Partial<
|
|
14
|
+
Record<ConnectorWorkspaceEnvKey, string>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
function hasControlCharacter(value: string): boolean {
|
|
18
|
+
for (const character of value) {
|
|
19
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
20
|
+
if (codePoint <= 31 || codePoint === 127) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Accept no execution environment semantics except absolute data/cache roots.
|
|
29
|
+
* An absent environment is valid for legacy, manually configured entries.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeConnectorWorkspaceEnvironment(
|
|
32
|
+
input: unknown
|
|
33
|
+
): ConnectorWorkspaceEnvironment | null {
|
|
34
|
+
if (input === undefined) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const prototype = Object.getPrototypeOf(input);
|
|
41
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const record = input as Record<string, unknown>;
|
|
46
|
+
const allowed = new Set<string>(CONNECTOR_WORKSPACE_ENV_KEYS);
|
|
47
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const normalized: ConnectorWorkspaceEnvironment = {};
|
|
52
|
+
for (const key of CONNECTOR_WORKSPACE_ENV_KEYS) {
|
|
53
|
+
const value = record[key];
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
typeof value !== "string" ||
|
|
59
|
+
!value ||
|
|
60
|
+
!isAbsolute(value) ||
|
|
61
|
+
hasControlCharacter(value)
|
|
62
|
+
) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
normalized[key] = normalize(value);
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** Security policy and remediation for connector activation verification. */
|
|
2
|
+
|
|
3
|
+
// node:fs/promises realpath has no Bun equivalent for symlink-safe provenance.
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
|
+
// node:path has no Bun equivalent for portable path identity checks.
|
|
6
|
+
import { basename, isAbsolute } from "node:path";
|
|
7
|
+
|
|
8
|
+
import type { ActivationVerificationCode } from "../store/types";
|
|
9
|
+
|
|
10
|
+
import { isValidIndexName } from "../app/index-name";
|
|
11
|
+
import { getCurrentGnoEntrypoint } from "./runtime-entrypoint";
|
|
12
|
+
|
|
13
|
+
export type ConnectorVerificationCode = Extract<
|
|
14
|
+
ActivationVerificationCode,
|
|
15
|
+
| "connector_not_configured"
|
|
16
|
+
| "connector_probe_unavailable"
|
|
17
|
+
| "connector_unsupported_config"
|
|
18
|
+
| "connector_start_failed"
|
|
19
|
+
| "connector_timeout"
|
|
20
|
+
| "connector_missing_tools"
|
|
21
|
+
| "connector_status_failed"
|
|
22
|
+
| "connector_search_failed"
|
|
23
|
+
| "connector_result_mismatch"
|
|
24
|
+
| "target_runtime_unverifiable"
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
export interface ConnectorCommandPolicyOptions {
|
|
28
|
+
/** Extra installer-resolved GNO entry paths; primarily for packaged runtimes. */
|
|
29
|
+
trustedGnoEntryPaths?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isGnoExecutable(path: string): boolean {
|
|
33
|
+
const executable = basename(path).toLowerCase();
|
|
34
|
+
return (
|
|
35
|
+
executable === "gno" || executable === "gno.exe" || executable === "gno.cmd"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function realpathOrNull(path: string): Promise<string | null> {
|
|
40
|
+
try {
|
|
41
|
+
return await realpath(path);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function resolveExecutableIdentity(
|
|
48
|
+
command: string
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
if (!isAbsolute(command)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return realpathOrNull(command);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function trustedGnoIdentities(
|
|
57
|
+
options: ConnectorCommandPolicyOptions
|
|
58
|
+
): Promise<Set<string>> {
|
|
59
|
+
// Conventional install paths are locators, not trust roots: symlinks resolve
|
|
60
|
+
// to this package entrypoint; standalone wrappers must be explicitly trusted.
|
|
61
|
+
const candidates = [
|
|
62
|
+
getCurrentGnoEntrypoint(),
|
|
63
|
+
...(options.trustedGnoEntryPaths ?? []),
|
|
64
|
+
];
|
|
65
|
+
const identities = new Set<string>();
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const identity = await realpathOrNull(candidate);
|
|
68
|
+
if (identity) {
|
|
69
|
+
identities.add(identity);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return identities;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function hasTrustedGnoIdentity(
|
|
76
|
+
path: string,
|
|
77
|
+
trusted: Set<string>
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
const identity = await realpathOrNull(path);
|
|
80
|
+
return identity !== null && trusted.has(identity);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function hasTrustedGnoExecutableIdentity(
|
|
84
|
+
command: string,
|
|
85
|
+
trusted: Set<string>
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
const identity = await resolveExecutableIdentity(command);
|
|
88
|
+
return identity !== null && trusted.has(identity);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const SAFE_GLOBAL_VALUE_FLAGS = ["--index", "--config"] as const;
|
|
92
|
+
const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
|
|
93
|
+
|
|
94
|
+
function isSafeConfigPath(value: string): boolean {
|
|
95
|
+
return isAbsolute(value) && !CONTROL_CHARACTER_PATTERN.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isSafeReadOnlyMcpArgs(args: string[]): boolean {
|
|
99
|
+
const seenFlags = new Set<string>();
|
|
100
|
+
let position = 0;
|
|
101
|
+
while (position < args.length) {
|
|
102
|
+
const argument = args[position];
|
|
103
|
+
if (argument === "mcp") {
|
|
104
|
+
const remainder = args.slice(position + 1);
|
|
105
|
+
return (
|
|
106
|
+
remainder.length === 0 ||
|
|
107
|
+
(remainder.length === 1 && remainder[0] === "serve")
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const flag = SAFE_GLOBAL_VALUE_FLAGS.find(
|
|
112
|
+
(candidate) =>
|
|
113
|
+
argument === candidate || argument?.startsWith(`${candidate}=`)
|
|
114
|
+
);
|
|
115
|
+
if (!flag || seenFlags.has(flag)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
seenFlags.add(flag);
|
|
119
|
+
|
|
120
|
+
if (argument === flag) {
|
|
121
|
+
const value = args[position + 1];
|
|
122
|
+
if (
|
|
123
|
+
!value ||
|
|
124
|
+
value.startsWith("-") ||
|
|
125
|
+
(flag === "--index" && !isValidIndexName(value)) ||
|
|
126
|
+
(flag === "--config" && !isSafeConfigPath(value))
|
|
127
|
+
) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
position += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const value = argument?.slice(flag.length + 1);
|
|
135
|
+
if (
|
|
136
|
+
!value ||
|
|
137
|
+
(flag === "--index" && !isValidIndexName(value)) ||
|
|
138
|
+
(flag === "--config" && !isSafeConfigPath(value))
|
|
139
|
+
) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
position += 1;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Accept only provenance-verified direct GNO or Bun-to-local-GNO shapes. */
|
|
148
|
+
export async function isSafeLocalGnoMcpCommand(
|
|
149
|
+
entry: { command: string; args: string[] },
|
|
150
|
+
options: ConnectorCommandPolicyOptions = {}
|
|
151
|
+
): Promise<boolean> {
|
|
152
|
+
const commandName = basename(entry.command).toLowerCase();
|
|
153
|
+
const args = entry.args;
|
|
154
|
+
if (
|
|
155
|
+
!entry.command ||
|
|
156
|
+
!isAbsolute(entry.command) ||
|
|
157
|
+
args.length === 0 ||
|
|
158
|
+
args.includes("--enable-write") ||
|
|
159
|
+
commandName === "bunx" ||
|
|
160
|
+
commandName === "bunx.exe" ||
|
|
161
|
+
commandName === "npx" ||
|
|
162
|
+
commandName === "npx.cmd" ||
|
|
163
|
+
commandName === "npx.exe"
|
|
164
|
+
) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const trusted = await trustedGnoIdentities(options);
|
|
169
|
+
if (isGnoExecutable(entry.command)) {
|
|
170
|
+
return (
|
|
171
|
+
(await hasTrustedGnoExecutableIdentity(entry.command, trusted)) &&
|
|
172
|
+
isSafeReadOnlyMcpArgs(args)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (commandName !== "bun" && commandName !== "bun.exe") {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (args[0] === "x" || args[0] === "bunx") {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const [configuredBun, runtimeBun] = await Promise.all([
|
|
182
|
+
resolveExecutableIdentity(entry.command),
|
|
183
|
+
realpathOrNull(process.execPath),
|
|
184
|
+
]);
|
|
185
|
+
if (!configuredBun || configuredBun !== runtimeBun) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (isGnoExecutable(args[0] ?? "")) {
|
|
189
|
+
return (
|
|
190
|
+
(await hasTrustedGnoIdentity(args[0] ?? "", trusted)) &&
|
|
191
|
+
isSafeReadOnlyMcpArgs(args.slice(1))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
args[0] === "run" &&
|
|
196
|
+
(await hasTrustedGnoIdentity(args[1] ?? "", trusted)) &&
|
|
197
|
+
isSafeReadOnlyMcpArgs(args.slice(2))
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Stable, bounded remediation; never includes child output or corpus text. */
|
|
202
|
+
export function getConnectorVerificationRemediation(
|
|
203
|
+
code: ConnectorVerificationCode,
|
|
204
|
+
target: string
|
|
205
|
+
): string {
|
|
206
|
+
const label =
|
|
207
|
+
target
|
|
208
|
+
.replace(/[^\p{L}\p{N}._ -]/gu, "?")
|
|
209
|
+
.trim()
|
|
210
|
+
.slice(0, 80) || "connector";
|
|
211
|
+
switch (code) {
|
|
212
|
+
case "connector_not_configured":
|
|
213
|
+
return `Install or configure the ${label} connector, then retry verification.`;
|
|
214
|
+
case "target_runtime_unverifiable":
|
|
215
|
+
return `${label} exposes no safe read-only runtime verification hook; installation is present but execution is unverified.`;
|
|
216
|
+
case "connector_probe_unavailable":
|
|
217
|
+
return "Repair the local lexical activation proof before verifying the connector.";
|
|
218
|
+
case "connector_unsupported_config":
|
|
219
|
+
return `Replace the ${label} entry with a local read-only GNO MCP command.`;
|
|
220
|
+
case "connector_start_failed":
|
|
221
|
+
return `Check the configured ${label} GNO executable and local permissions.`;
|
|
222
|
+
case "connector_timeout":
|
|
223
|
+
return `Retry ${label} verification; inspect local MCP startup if it times out again.`;
|
|
224
|
+
case "connector_missing_tools":
|
|
225
|
+
return `Update the ${label} GNO MCP server so gno_status and gno_search are available.`;
|
|
226
|
+
case "connector_status_failed":
|
|
227
|
+
return `Run gno status locally and repair the index before retrying ${label}.`;
|
|
228
|
+
case "connector_search_failed":
|
|
229
|
+
return `Run a collection-scoped lexical search locally before retrying ${label}.`;
|
|
230
|
+
case "connector_result_mismatch":
|
|
231
|
+
return `Reindex the collection and retry ${label}; the connector did not return the expected source.`;
|
|
232
|
+
}
|
|
233
|
+
}
|