@frockbot/plugin-bot-template 0.0.0 → 0.1.1

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/src/scrub.ts ADDED
@@ -0,0 +1,523 @@
1
+ // Building the pack — the only place scrubbing happens.
2
+ //
3
+ // The register's contract, verbatim (`docs/research/grokbot-computer.md` line
4
+ // 326-328): the host **never falls back to the owner's live files** — a
5
+ // selected item whose content is missing is filtered out rather than re-read;
6
+ // scrubbing lives **only in the pack arguments, never in the live files**;
7
+ // managed, plugin and built-in Skills are always excluded.
8
+ //
9
+ // So this module is a pure function. It takes a description of what the Bot
10
+ // already is and returns a `BotTemplateV1`; it reads nothing, writes nothing,
11
+ // and cannot reach a Workspace, a Connection, or a keyring even by accident.
12
+ // Every row of the scrub matrix is decided here and nowhere else, which is what
13
+ // makes the matrix testable as a table of plain objects.
14
+ //
15
+ // What is refused, and why:
16
+ //
17
+ // Memory, transcripts, unread state, Computer files a template is
18
+ // public-shareable and Memory is the User's facts under a durable root
19
+ // (ADR 0015 records the divergence from GrokBot's `memory:[…]`).
20
+ // Connections, `connectionId`, `safeMetadata`, Assignments "A Bot receives
21
+ // authority solely through an explicit, durable Assignment and, when
22
+ // required, a Connection." An import must not inherit either.
23
+ // `PackageInstallationView.values` setup fields may hold keys.
24
+ // The model assignment it names a Connection.
25
+ import {
26
+ MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1,
27
+ MAX_TEMPLATE_SKILL_BODY_BYTES_V1,
28
+ MAX_TEMPLATE_PACKAGES_V1,
29
+ MAX_TEMPLATE_ROUTINES_V1,
30
+ MAX_TEMPLATE_SERVERS_V1,
31
+ MAX_TEMPLATE_SKILLS_V1,
32
+ decodeBotTemplateV1,
33
+ type BotTemplateV1,
34
+ type TemplateMcpServerV1,
35
+ type TemplatePackageV1,
36
+ type TemplateRoutineV1,
37
+ type TemplateSheepRecipeV1,
38
+ type TemplateSkillV1,
39
+ } from "@frockbot/template-core";
40
+ import type {
41
+ TemplateExportSummaryV1,
42
+ TemplateOmissionReasonV1,
43
+ TemplateOmissionV1,
44
+ } from "./shared.js";
45
+
46
+ /**
47
+ * One Skill candidate, as the Bot's own catalog presents it.
48
+ *
49
+ * `source` and `writer` are carried rather than pre-filtered so the matrix is
50
+ * decided here: the Skills loader already refuses an unattributed writer, and
51
+ * this refuses it again. Two independent refusals of the same rule is the
52
+ * point — an instruction that reached a durable root outside the Workspace file
53
+ * surface is data, never an instruction, and never a thing a template teaches
54
+ * someone else's Bot to run.
55
+ */
56
+ export interface TemplateSkillCandidateV1 {
57
+ source: "bot" | "managed" | "plugin";
58
+ slug?: string;
59
+ name: string;
60
+ description?: string;
61
+ /** Absent when the body failed to load. Such a Skill is dropped, never re-read. */
62
+ body?: string;
63
+ writer: { kind: "bot" | "user" | "first-party" | "unattributed" };
64
+ }
65
+
66
+ /** One Routine candidate, in the shape `RoutineViewV1` already has. */
67
+ export interface TemplateRoutineCandidateV1 {
68
+ routineId: string;
69
+ name: string;
70
+ prompt: string;
71
+ schedule?: string;
72
+ trigger?: { kind: "webhook" };
73
+ timezone: string;
74
+ }
75
+
76
+ /** One installed Package, in the shape `PackageInstallationView` already has. */
77
+ export interface TemplatePackageCandidateV1 {
78
+ packageId: string;
79
+ version: string;
80
+ state: "installed" | "disabled" | "failed";
81
+ catalogId?: string;
82
+ catalogGeneration?: string;
83
+ provenance?: "first-party" | "catalog";
84
+ /** Setup values. Present here only so the omission can be counted. */
85
+ values?: Record<string, unknown>;
86
+ /** The Catalog's own display name, when the pinned generation still has it. */
87
+ displayName?: string;
88
+ }
89
+
90
+ /**
91
+ * One Connection candidate.
92
+ *
93
+ * `settings` is the only field carried, and only `url` and `transport` are ever
94
+ * read out of it. A `ConnectionView` also has `connectionId`, `safeMetadata`,
95
+ * `authorization` and `generation`; none of them is in this shape, so no
96
+ * refactor can leak one by forgetting to strip it.
97
+ */
98
+ export interface TemplateConnectionCandidateV1 {
99
+ packageId: string;
100
+ connectionTypeId: string;
101
+ displayName: string;
102
+ state: string;
103
+ /** Whether this Connection Type needs a credential the importer must supply. */
104
+ keyed: boolean;
105
+ settings?: { url?: unknown; transport?: unknown };
106
+ }
107
+
108
+ export interface TemplateSourceV1 {
109
+ botId: string;
110
+ profile: {
111
+ name: string;
112
+ title?: string;
113
+ description?: string;
114
+ };
115
+ /**
116
+ * The recipe the exported profile carries: this Bot's own generated sheep.
117
+ *
118
+ * A `SheepRecipeV1` is four layer ids — deterministic, tiny, and nobody's
119
+ * photograph — so it travels (ADR 0015, D1).
120
+ */
121
+ sheep: TemplateSheepRecipeV1;
122
+ skills: readonly TemplateSkillCandidateV1[];
123
+ routines: readonly TemplateRoutineCandidateV1[];
124
+ packages: readonly TemplatePackageCandidateV1[];
125
+ connections: readonly TemplateConnectionCandidateV1[];
126
+ /** True when the Bot has a model assignment; it names a Connection, so it goes. */
127
+ hasModelAssignment?: boolean;
128
+ /** How many Assignments the Bot holds; counted, never carried. */
129
+ assignmentCount?: number;
130
+ sourceCatalogGeneration?: string;
131
+ }
132
+
133
+ export interface TemplateBuildResultV1 {
134
+ template: BotTemplateV1;
135
+ summary: TemplateExportSummaryV1;
136
+ }
137
+
138
+ /**
139
+ * A private-network or non-https URL never reaches a template.
140
+ *
141
+ * `plugin-mcp/src/ssrf.ts` refuses one on the way *out* of the deployment. A
142
+ * template travels further than that: it is handed to another User, whose
143
+ * deployment would be the one making the request. So the same classifier runs
144
+ * here, and a server that fails it is exported as a placeholder with no URL at
145
+ * all rather than as a public server someone else's Bot would dial.
146
+ */
147
+ const BLOCKED_HOST_SUFFIXES = [
148
+ ".local",
149
+ ".internal",
150
+ ".localhost",
151
+ ".home.arpa",
152
+ ];
153
+
154
+ const BLOCKED_HOSTNAMES = new Set([
155
+ "localhost",
156
+ "metadata.google.internal",
157
+ "metadata",
158
+ ]);
159
+
160
+ function isIpv4Literal(host: string): number[] | undefined {
161
+ const parts = host.split(".");
162
+ if (parts.length !== 4) return undefined;
163
+ const octets = parts.map((part) =>
164
+ /^\d{1,3}$/.test(part) ? Number(part) : Number.NaN,
165
+ );
166
+ if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) {
167
+ return undefined;
168
+ }
169
+ return octets;
170
+ }
171
+
172
+ function isPrivateIpv4(octets: number[]): boolean {
173
+ const [a = 0, b = 0] = octets;
174
+ if (a === 0 || a === 10 || a === 127) return true;
175
+ if (a === 169 && b === 254) return true;
176
+ if (a === 172 && b >= 16 && b <= 31) return true;
177
+ if (a === 192 && b === 168) return true;
178
+ if (a === 100 && b >= 64 && b <= 127) return true;
179
+ if (a === 198 && (b === 18 || b === 19)) return true;
180
+ if (a >= 224) return true;
181
+ return false;
182
+ }
183
+
184
+ function isPrivateIpv6(host: string): boolean {
185
+ const inner =
186
+ host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
187
+ if (!inner.includes(":")) return false;
188
+ if (inner === "::" || inner === "::1") return true;
189
+ if (/^f[cd]/.test(inner) || /^fe[89ab]/.test(inner)) return true;
190
+ const mapped = inner.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
191
+ if (mapped?.[1]) {
192
+ const octets = isIpv4Literal(mapped[1]);
193
+ return octets ? isPrivateIpv4(octets) : true;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ /** The shareable form of a server URL, or `undefined` when it must not travel. */
199
+ export function shareableServerUrlV1(value: unknown): string | undefined {
200
+ if (typeof value !== "string" || !value || value.length > 2_048) {
201
+ return undefined;
202
+ }
203
+ const url = URL.parse(value);
204
+ if (!url || url.protocol !== "https:" || url.username || url.password) {
205
+ return undefined;
206
+ }
207
+ const host = url.hostname.toLowerCase();
208
+ if (BLOCKED_HOSTNAMES.has(host)) return undefined;
209
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
210
+ return undefined;
211
+ }
212
+ const octets = isIpv4Literal(host);
213
+ if (octets && isPrivateIpv4(octets)) return undefined;
214
+ if (isPrivateIpv6(host)) return undefined;
215
+ return value;
216
+ }
217
+
218
+ /**
219
+ * A slug for a template entry, derived from a readable name.
220
+ *
221
+ * Names become roles (line 330): the slug is what an importing Bot's own
222
+ * instruction root and Routine list will use, so it is derived from the name
223
+ * rather than copied from an id that means something only in the source
224
+ * deployment.
225
+ */
226
+ export function templateSlugV1(value: string, fallback: string): string {
227
+ const slug = value
228
+ .toLowerCase()
229
+ .normalize("NFKD")
230
+ .replace(/[^a-z0-9]+/g, "-")
231
+ .replace(/^-|-$/g, "")
232
+ .slice(0, 96);
233
+ return slug || fallback;
234
+ }
235
+
236
+ function uniqueSlug(slug: string, taken: Set<string>): string {
237
+ if (!taken.has(slug)) {
238
+ taken.add(slug);
239
+ return slug;
240
+ }
241
+ for (let suffix = 2; ; suffix += 1) {
242
+ const candidate = `${slug.slice(0, 90)}-${suffix}`;
243
+ if (!taken.has(candidate)) {
244
+ taken.add(candidate);
245
+ return candidate;
246
+ }
247
+ }
248
+ }
249
+
250
+ class Omissions {
251
+ private readonly counts = new Map<TemplateOmissionReasonV1, number>();
252
+
253
+ add(reason: TemplateOmissionReasonV1, by = 1): void {
254
+ if (by <= 0) return;
255
+ this.counts.set(reason, (this.counts.get(reason) ?? 0) + by);
256
+ }
257
+
258
+ list(): TemplateOmissionV1[] {
259
+ return [...this.counts.entries()].map(([reason, count]) => ({
260
+ reason,
261
+ count,
262
+ }));
263
+ }
264
+ }
265
+
266
+ function scrubSkills(
267
+ source: TemplateSourceV1,
268
+ omissions: Omissions,
269
+ ): TemplateSkillV1[] {
270
+ const slugs = new Set<string>();
271
+ const skills: TemplateSkillV1[] = [];
272
+ for (const candidate of source.skills) {
273
+ if (candidate.source === "managed") {
274
+ omissions.add("managed-skill");
275
+ continue;
276
+ }
277
+ if (candidate.source === "plugin") {
278
+ omissions.add("plugin-skill");
279
+ continue;
280
+ }
281
+ if (
282
+ candidate.writer.kind === "unattributed" ||
283
+ candidate.writer.kind === "first-party"
284
+ ) {
285
+ omissions.add("unattributed-skill");
286
+ continue;
287
+ }
288
+ // No fallback. A Skill whose body did not load, or whose directory is not
289
+ // a well-formed slug, is dropped here; nothing re-reads the owner's live
290
+ // instruction root to fill the gap.
291
+ if (
292
+ !candidate.body ||
293
+ candidate.body.length > MAX_TEMPLATE_SKILL_BODY_BYTES_V1
294
+ ) {
295
+ omissions.add("unreadable-skill");
296
+ continue;
297
+ }
298
+ if (skills.length >= MAX_TEMPLATE_SKILLS_V1) {
299
+ omissions.add("unreadable-skill");
300
+ continue;
301
+ }
302
+ const slug = uniqueSlug(
303
+ candidate.slug ?? templateSlugV1(candidate.name, "skill"),
304
+ slugs,
305
+ );
306
+ skills.push({
307
+ slug,
308
+ name: candidate.name.slice(0, 100),
309
+ ...(candidate.description === undefined
310
+ ? {}
311
+ : { description: candidate.description.slice(0, 2_000) }),
312
+ body: candidate.body,
313
+ });
314
+ }
315
+ return skills;
316
+ }
317
+
318
+ function scrubRoutines(source: TemplateSourceV1): TemplateRoutineV1[] {
319
+ const slugs = new Set<string>();
320
+ const routines: TemplateRoutineV1[] = [];
321
+ for (const candidate of source.routines) {
322
+ if (routines.length >= MAX_TEMPLATE_ROUTINES_V1) break;
323
+ if (!candidate.prompt) continue;
324
+ const webhook = candidate.trigger?.kind === "webhook";
325
+ routines.push({
326
+ slug: uniqueSlug(templateSlugV1(candidate.name, "routine"), slugs),
327
+ name: candidate.name.slice(0, 100),
328
+ prompt: candidate.prompt.slice(0, MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1),
329
+ // A webhook Routine carries its kind and nothing else. The key and its
330
+ // digest never leave the Bot Durable Object that minted them, and a
331
+ // template is a weaker place still.
332
+ ...(webhook || !candidate.schedule
333
+ ? {}
334
+ : { schedule: candidate.schedule.slice(0, 256) }),
335
+ timezone: candidate.timezone.slice(0, 64) || "UTC",
336
+ ...(webhook
337
+ ? { triggerKind: "webhook" as const }
338
+ : candidate.schedule
339
+ ? { triggerKind: "cron" as const }
340
+ : {}),
341
+ });
342
+ }
343
+ return routines;
344
+ }
345
+
346
+ function scrubPackages(
347
+ source: TemplateSourceV1,
348
+ omissions: Omissions,
349
+ ): TemplatePackageV1[] {
350
+ const packages: TemplatePackageV1[] = [];
351
+ const seen = new Set<string>();
352
+ for (const candidate of source.packages) {
353
+ if (candidate.values !== undefined) omissions.add("package-values");
354
+ if (candidate.state !== "installed") continue;
355
+ if (
356
+ candidate.provenance === "first-party" ||
357
+ candidate.provenance === undefined ||
358
+ !candidate.catalogId
359
+ ) {
360
+ // Nothing to install: a first-party Package is compiled into whatever
361
+ // application the importer is running, so a reference would be noise.
362
+ omissions.add("first-party-package");
363
+ continue;
364
+ }
365
+ if (seen.has(candidate.catalogId)) continue;
366
+ if (packages.length >= MAX_TEMPLATE_PACKAGES_V1) continue;
367
+ seen.add(candidate.catalogId);
368
+ packages.push({
369
+ packageId: candidate.packageId,
370
+ catalogId: candidate.catalogId,
371
+ version: candidate.version.slice(0, 100),
372
+ displayName: (candidate.displayName || candidate.packageId).slice(0, 100),
373
+ });
374
+ }
375
+ return packages;
376
+ }
377
+
378
+ function scrubServers(
379
+ source: TemplateSourceV1,
380
+ omissions: Omissions,
381
+ ): TemplateMcpServerV1[] {
382
+ const servers: TemplateMcpServerV1[] = [];
383
+ for (const candidate of source.connections) {
384
+ // Every Connection is omitted as a Connection: what may travel is a
385
+ // *description* of the server it points at, never the Connection itself.
386
+ omissions.add("connection");
387
+ if (servers.length >= MAX_TEMPLATE_SERVERS_V1) continue;
388
+ if (candidate.state !== "ready") continue;
389
+ // What may travel is a description of a *server*, and a Connection is one
390
+ // only when it names an endpoint. A model account, a provider grant, or
391
+ // any other Connection has nothing a recipe could describe, so it is
392
+ // omitted as a Connection and nothing else — rather than becoming a
393
+ // placeholder telling the importer to connect something that is not a
394
+ // server at all.
395
+ if (candidate.settings?.url === undefined) continue;
396
+ const url = shareableServerUrlV1(candidate.settings.url);
397
+ if (candidate.keyed) {
398
+ if (url === undefined) omissions.add("private-network-server");
399
+ // A keyed server is always a placeholder: the importer supplies their own
400
+ // key, and the URL is not carried at all, so a custom server behind a
401
+ // private network cannot be pointed at from someone else's deployment.
402
+ servers.push({
403
+ kind: "needs-connection",
404
+ name: candidate.displayName.slice(0, 100),
405
+ connectionTypeId: candidate.connectionTypeId,
406
+ hint: "This server needs your own Connection and credential.",
407
+ });
408
+ continue;
409
+ }
410
+ if (url === undefined) {
411
+ omissions.add("private-network-server");
412
+ servers.push({
413
+ kind: "needs-connection",
414
+ name: candidate.displayName.slice(0, 100),
415
+ connectionTypeId: candidate.connectionTypeId,
416
+ hint: "This server's address is not reachable from another deployment.",
417
+ });
418
+ continue;
419
+ }
420
+ const transport =
421
+ candidate.settings?.transport === "sse" ? "sse" : "streamable-http";
422
+ servers.push({
423
+ kind: "public",
424
+ name: candidate.displayName.slice(0, 100),
425
+ url,
426
+ transport,
427
+ });
428
+ }
429
+ return servers;
430
+ }
431
+
432
+ /** Build one template from what the Bot already is. Pure; never re-reads. */
433
+ export function buildBotTemplateV1(
434
+ source: TemplateSourceV1,
435
+ ): TemplateBuildResultV1 {
436
+ const omissions = new Omissions();
437
+ if (source.hasModelAssignment) omissions.add("model");
438
+ omissions.add("assignment", source.assignmentCount ?? 0);
439
+ // Memory is never read, so there is nothing to count; the omission is
440
+ // recorded unconditionally because it is the one a User most needs told.
441
+ omissions.add("memory");
442
+
443
+ const skills = scrubSkills(source, omissions);
444
+ const routines = scrubRoutines(source);
445
+ const packages = scrubPackages(source, omissions);
446
+ const mcpServers = scrubServers(source, omissions);
447
+
448
+ const template = decodeBotTemplateV1({
449
+ schemaVersion: 1,
450
+ profile: {
451
+ name: source.profile.name.slice(0, 100),
452
+ ...(source.profile.title
453
+ ? { title: source.profile.title.slice(0, 120) }
454
+ : {}),
455
+ ...(source.profile.description
456
+ ? { description: source.profile.description.slice(0, 10_000) }
457
+ : {}),
458
+ avatar: { kind: "sheep", recipe: source.sheep },
459
+ },
460
+ skills,
461
+ routines,
462
+ packages,
463
+ mcpServers,
464
+ ...(source.sourceCatalogGeneration
465
+ ? { sourceCatalogGeneration: source.sourceCatalogGeneration }
466
+ : {}),
467
+ });
468
+
469
+ return {
470
+ template,
471
+ summary: {
472
+ schemaVersion: 1,
473
+ botId: source.botId,
474
+ skills: template.skills.length,
475
+ routines: template.routines.length,
476
+ packages: template.packages.length,
477
+ publicServers: template.mcpServers.filter(
478
+ (server) => server.kind === "public",
479
+ ).length,
480
+ needsConnection: template.mcpServers.filter(
481
+ (server) => server.kind === "needs-connection",
482
+ ).length,
483
+ omitted: omissions.list(),
484
+ },
485
+ };
486
+ }
487
+
488
+ /** One line per section, for the `agent-card` a Bot returns. */
489
+ export function describeTemplateSummaryV1(
490
+ summary: TemplateExportSummaryV1,
491
+ ): string {
492
+ const packed = [
493
+ `${summary.skills} Skill${summary.skills === 1 ? "" : "s"}`,
494
+ `${summary.routines} Routine${summary.routines === 1 ? "" : "s"}`,
495
+ `${summary.packages} Package${summary.packages === 1 ? "" : "s"}`,
496
+ `${summary.publicServers} public MCP server${summary.publicServers === 1 ? "" : "s"}`,
497
+ ].join(", ");
498
+ const scrubbed: string[] = ["Memory", "Connections", "Assignments"];
499
+ if (summary.needsConnection > 0) {
500
+ scrubbed.push(
501
+ `${summary.needsConnection} server${summary.needsConnection === 1 ? "" : "s"} left as a placeholder`,
502
+ );
503
+ }
504
+ for (const omission of summary.omitted) {
505
+ if (omission.reason === "managed-skill") {
506
+ scrubbed.push(`${omission.count} managed Skill(s)`);
507
+ }
508
+ if (omission.reason === "plugin-skill") {
509
+ scrubbed.push(`${omission.count} plugin Skill(s)`);
510
+ }
511
+ if (omission.reason === "unattributed-skill") {
512
+ scrubbed.push(`${omission.count} Skill(s) with no recorded writer`);
513
+ }
514
+ if (omission.reason === "package-values") {
515
+ scrubbed.push("Package setup values");
516
+ }
517
+ }
518
+ return [
519
+ `Packed ${packed}.`,
520
+ `Scrubbed: ${scrubbed.join("; ")}.`,
521
+ "Nothing is shared until you choose a visibility.",
522
+ ].join(" ");
523
+ }