@gmickel/gno 1.12.4 → 1.14.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.
Files changed (61) hide show
  1. package/README.md +126 -59
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +18 -7
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +3 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -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
+ }