@tapcue/extension-sdk 0.1.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.
@@ -0,0 +1,1572 @@
1
+ /**
2
+ * Manifest types and the non-executing validator.
3
+ *
4
+ * The manifest is readable without running any JavaScript: static commands,
5
+ * their arguments, settings, and the permission upper bound all come from here.
6
+ * `validateManifest` is the executable spec — the installer (Rust) must reject
7
+ * exactly what this rejects.
8
+ */
9
+
10
+ import type { JsonValue } from "./json.js";
11
+ import {
12
+ CONTRIBUTABLE_SCOPE_TYPES,
13
+ CONTRIBUTION_MATCH_FIELDS,
14
+ isContributableScopeType,
15
+ } from "./types.js";
16
+ import type { ContributableScopeType } from "./types.js";
17
+ import {
18
+ BOOLEAN_PERMISSION_GROUPS,
19
+ FLAG_PERMISSION_UNITS,
20
+ PERMISSION_UNITS,
21
+ } from "./permission-units.js";
22
+
23
+ export type { PermissionUnit } from "./permission-units.js";
24
+ import { HEX_COLOR_PATTERN } from "./scene.js";
25
+
26
+ export const MANIFEST_SCHEMA_VERSION = 1;
27
+ export const SDK_API_VERSION = "1.0";
28
+
29
+ /**
30
+ * The action id a `no-view` command is invoked with when the user runs its row.
31
+ *
32
+ * A `view` command names its action per item (`CommandItem.defaultActionId`), but a
33
+ * `no-view` command *is* its action: its row comes from the manifest, so there is no
34
+ * item to carry an id. Core routes Enter on that row to `invoke` with this reserved
35
+ * id. Further actions on the row (the ⌘K panel) still use their own ids.
36
+ */
37
+ export const DEFAULT_ACTION_ID = "default";
38
+
39
+ /** The names from the capability table. One unit, one all-or-nothing approval. */
40
+ export type PackageType = "commands" | "skill" | "extension" | "script";
41
+ export type RuntimeId = "deno";
42
+
43
+ export interface ExtensionManifest {
44
+ schemaVersion: 1;
45
+ type: PackageType;
46
+ runtime?: RuntimeId;
47
+ id: string;
48
+ /** A leading `@` makes any user-facing string a catalog key; see `i18n.ts`. */
49
+ name: string;
50
+ description: string;
51
+ version: string;
52
+ engines: { tapcue: string };
53
+ /** SDK API version range the bundle was built against (`extension` type only). */
54
+ api?: string;
55
+ entry?: string;
56
+ /** Catalog used when the user's locale has none. Defaults to `en`. */
57
+ defaultLocale?: string;
58
+ /**
59
+ * The extension's icon, and the fallback for any command that declares none.
60
+ * It is in the manifest because a command row must be drawable — icon included —
61
+ * without executing a line of the extension's code.
62
+ */
63
+ icon?: string;
64
+ /**
65
+ * **The apps this extension integrates with**, by bundle identifier (spec 016 §1).
66
+ *
67
+ * One declaration, four uses: `contributes[].when.bundleId` may only name apps from here; a
68
+ * `files.read` grant's `container` root is resolved under one of them; an `exec` grant's `inApp`
69
+ * binary is resolved inside one of their bundles; and `native.open` will accept a custom scheme
70
+ * only when the system's handler for it *is* one of them.
71
+ *
72
+ * That is the same rule §11.3 runs on — **the extension names a kind, the host decides the
73
+ * location** — so an extension can never write an absolute path, name somebody else's bundle, or
74
+ * hand a URL to an app it did not disclose. And the install sheet says it once ("Works with
75
+ * 1Password") instead of four times in four vocabularies.
76
+ *
77
+ * Absent or empty for almost every extension. The forms above simply become undeclarable.
78
+ */
79
+ integratesWith?: string[];
80
+ commands: ManifestCommand[];
81
+ /**
82
+ * **Rows this extension adds to scopes it does not own** (architecture §11.3) — the third
83
+ * right over a scope, next to owning one and producing an item that enters one.
84
+ *
85
+ * In the manifest and not in code, for the same reason commands are: the user enters the
86
+ * scope, so Tapcue must know who to ask *without executing anything*. Entering an app's scope
87
+ * must no more run every installed extension than opening root search does. A scope the
88
+ * extension **owns** is the opposite case — reachable only through a row it produced — and
89
+ * stays in code (`defineScope`).
90
+ */
91
+ contributes?: ManifestContribution[];
92
+ /**
93
+ * **Slots this extension fills** — the mirror image of `contributes`.
94
+ *
95
+ * A contribution adds the extension's rows to a place Tapcue owns. A provision answers a
96
+ * *question* Tapcue asks: a slot is a named question with a fixed answer shape, and the host
97
+ * calls whoever declared it, when it needs the answer, and merges what comes back with its own.
98
+ *
99
+ * In the manifest for the same reason contributions are: the host has to know who to ask before
100
+ * anything has run. It asks when the panel opens, not on a keystroke — so the answer is a query,
101
+ * never an effect, and a slow one is simply late rather than felt.
102
+ */
103
+ provides?: ManifestProvision[];
104
+ settings?: ManifestSetting[];
105
+ /**
106
+ * **When this extension's own rows should be suggested** (spec 018 §8), in the rule DSL.
107
+ *
108
+ * A string, or an array of strings joined by newlines — JSON has no block literal, and a rule is
109
+ * three lines:
110
+ *
111
+ * ```json
112
+ * "suggestionRules": [
113
+ * "rule mail-reply (origin: extension \"com.example.ai-mail\"):",
114
+ * " when frontmost(\"com.apple.mail\")",
115
+ * " emit boost(own(\"reply-with-ai\"), lift: 1.4)"
116
+ * ]
117
+ * ```
118
+ *
119
+ * Declared rather than called for three reasons: it is reviewable at install ("this extension
120
+ * suggests when Mail is frontmost"), the host evaluates it with **zero isolate wake** — the
121
+ * extension runs only when its suggestion is actually picked — and it can be permission-gated
122
+ * (`permissions.suggestions.rules`) like anything else. `own(...)` names one of this manifest's
123
+ * own command ids; the host expands it, clamps every lift to the extension tier's [1, 1.5], and
124
+ * refuses anything aimed at somebody else's rows. The grammar is the host's: this side checks
125
+ * only that the field is text and that there is not too much of it.
126
+ */
127
+ suggestionRules?: string | string[];
128
+ /**
129
+ * Capabilities the extension cannot work without. Denying one means the
130
+ * extension is not enabled — so at runtime a required capability is always
131
+ * there, and extension code never checks for it.
132
+ */
133
+ permissions?: ManifestPermissions;
134
+ /**
135
+ * Capabilities the extension can do without. The user may deny any of them, and
136
+ * then the capability (or the individual method) is absent from the invocation
137
+ * context. An extension **must** stay useful without every one of these.
138
+ *
139
+ * A capability unit appears in exactly one of the two blocks — approval is
140
+ * all-or-nothing per unit, so `http`'s host list is not separately approvable.
141
+ */
142
+ optionalPermissions?: ManifestPermissions;
143
+ }
144
+
145
+ /**
146
+ * One contribution: a native scope, which of its subjects, and the rows to add there.
147
+ *
148
+ * ```json
149
+ * { "id": "vault-items",
150
+ * "scope": "application",
151
+ * "when": { "bundleId": ["com.1password.1password"] },
152
+ * "title": "@contributes.vault-items.title" }
153
+ * ```
154
+ */
155
+ export interface ManifestContribution {
156
+ /**
157
+ * Stable, kebab-case, unique inside the extension — and distinct from every command id, since
158
+ * both name rows the user can end up standing on. The key its handler is registered under in
159
+ * `defineExtension({ contributions })`.
160
+ */
161
+ id: string;
162
+ /** The native scope type to contribute into; one of `CONTRIBUTABLE_SCOPE_TYPES`. */
163
+ scope: ContributableScopeType;
164
+ /**
165
+ * **Which subjects — required, and exhaustive.**
166
+ *
167
+ * A map from a subject field to the values that match it, compared case-insensitively as whole
168
+ * strings. Only the fields in `CONTRIBUTION_MATCH_FIELDS` for that scope type, and at least
169
+ * one of them: a wildcard contribution to `application` would mean "tell me every app you
170
+ * ever open", which is not what any real contribution needs and is not a thing this manifest
171
+ * can ask for. Several fields is an AND; several values in one field is an OR.
172
+ *
173
+ * This is both the gate and the disclosure. Tapcue matches it in its own process, so a
174
+ * subject the predicate does not accept never reaches the extension — and the installer can
175
+ * say exactly where the rows will appear, read straight off the manifest.
176
+ */
177
+ when: Record<string, string[]>;
178
+ /**
179
+ * What to call this group of rows where the user decides about it — the install sheet's
180
+ * "Adds items to" line. Localizable (`@key`). It is not the rows' facet title: rows carry
181
+ * their own facet, because a contribution may section its rows however it likes.
182
+ */
183
+ title: string;
184
+ }
185
+
186
+ /**
187
+ * One provision: which slot, and the handler that answers it.
188
+ *
189
+ * ```json
190
+ * { "id": "codex-projects", "slot": "workspace.roots", "title": "@provides.codex-projects.title" }
191
+ * ```
192
+ */
193
+ export interface ManifestProvision {
194
+ /**
195
+ * Stable, kebab-case, unique inside the extension. The key its handler is registered under in
196
+ * `defineExtension({ provisions })`.
197
+ */
198
+ id: string;
199
+ /** Which slot it fills; one of `SLOT_TYPES`. */
200
+ slot: SlotType;
201
+ /**
202
+ * What to call it where the user decides about it. Localizable (`@key`). The slot's permission
203
+ * unit is what grants it; this is what the install sheet reads out beside that unit, because
204
+ * "may tell Tapcue where you work" is a sentence that needs a *who*.
205
+ */
206
+ title: string;
207
+ }
208
+
209
+ /**
210
+ * The slots a provision may fill. One entry today.
211
+ *
212
+ * `workspace.roots` — the project folders the user works in. Tapcue joins a relative path typed
213
+ * into home (`apps/api/config.ts`, pasted out of a terminal or a diff) against them, alongside the
214
+ * folder the frontmost app is showing. An extension that already knows which projects a tool is
215
+ * open on is the only thing that knows this; the launcher cannot read it out of the app.
216
+ */
217
+ export const SLOT_TYPES = ["workspace.roots"] as const;
218
+ export type SlotType = (typeof SLOT_TYPES)[number];
219
+
220
+ export interface ManifestCommand {
221
+ id: string;
222
+ title: string;
223
+ description: string;
224
+ mode: "view" | "no-view";
225
+ /**
226
+ * How Tapcue sources this command's rows. Required for `mode: "view"`, and
227
+ * meaningless — so rejected — for `mode: "no-view"`: a command with no `query`
228
+ * handler has no query to schedule.
229
+ */
230
+ queryMode?: "catalog" | "remote";
231
+ /** Defaults to the extension's icon. */
232
+ icon?: string;
233
+ keywords?: string[];
234
+ /**
235
+ * Free-text arguments the command accepts after its keyword. Tapcue parses
236
+ * them (keyword stripping is the core's job) and hands them to the extension
237
+ * in `QueryRequest.arguments`.
238
+ */
239
+ arguments?: ManifestArgument[];
240
+ /** Required for `queryMode: "remote"`; see `ActivationPolicy`. */
241
+ activation?: ActivationPolicy;
242
+ /** What this command's surface takes in its query field; see `CommandInput`. */
243
+ input?: CommandInput;
244
+ /** How the command's own surface is laid out; see `CommandSurface`. */
245
+ surface?: CommandSurface;
246
+ }
247
+
248
+ /**
249
+ * How a command's **own** surface is laid out.
250
+ *
251
+ * Scopes are declared in code because a scope is only reachable through an item the extension
252
+ * produced, so nothing about it is knowable until the extension has run. A command's own surface
253
+ * (§23.10 — a no-argument `view` command's row *enters* it) is the exception: it is reached from
254
+ * the static manifest row, stamped before any code runs, and was therefore always a `list`. What is
255
+ * knowable without executing anything belongs in the manifest, so it lives here.
256
+ */
257
+ export interface CommandSurface {
258
+ /**
259
+ * `list` (the default) or `grid`. A `grid` carries the chip row, and its chips are the surface's
260
+ * own facet sections — so a grid that groups its rows gets tabs without asking twice.
261
+ *
262
+ * The rowless layouts are absent on purpose: `canvas` and `pane` are filled by a *handler*, which
263
+ * is code, so they stay where code is.
264
+ */
265
+ layout?: "list" | "grid";
266
+ }
267
+
268
+ /**
269
+ * What a command's query field takes — **a description, not a request**.
270
+ *
271
+ * A colour command's field wants a colour, and the colour someone is about to type is very often
272
+ * already on their clipboard. An extension cannot read the clipboard (`clipboard.read` would hand
273
+ * over every password a manager last copied, for this) and cannot draw in the query field
274
+ * (extension-ui §2). So it says what belongs there, and Tapcue — which owns the clipboard, the
275
+ * field and the keyboard — decides whether it has something worth offering, shows it as ghost text,
276
+ * and completes it on Tab.
277
+ *
278
+ * **The extension learns nothing until the value is accepted.** No Tab, no query, no value: the
279
+ * suggestion is the shell's, and the first the extension hears of it is an ordinary `query` call
280
+ * carrying text the user chose to accept.
281
+ */
282
+ export interface CommandInput {
283
+ /**
284
+ * Native subject types Tapcue knows how to recognise a value of. `"color"` today, and the union
285
+ * is closed on purpose: a type the shell cannot *find* is a suggestion it can never make, and a
286
+ * field that quietly accepts one is a promise nothing keeps.
287
+ */
288
+ accepts?: "color"[];
289
+ /**
290
+ * A regular expression a candidate must match, for input the shell has no type for — an issue
291
+ * key, a tracking number, a coupon code. Tested against the clipboard's text only, and only when
292
+ * that text is short (the host caps it, because this pattern runs in the launcher's process).
293
+ * Anchor it: `^…$` is almost always what you mean.
294
+ */
295
+ pattern?: string;
296
+ /**
297
+ * May Tapcue put a value in the field? **Off by default.** Describing the input is one decision;
298
+ * letting the shell fill it in is another, and a command should not get the second by forgetting
299
+ * to say.
300
+ */
301
+ autocomplete?: boolean;
302
+ }
303
+
304
+ export interface ManifestArgument {
305
+ name: string;
306
+ title: string;
307
+ placeholder?: string;
308
+ type: "text";
309
+ required?: boolean;
310
+ }
311
+
312
+ /**
313
+ * When Tapcue is allowed to run this command's `query`.
314
+ *
315
+ * Note what is *not* here: debounce. How often a keystroke turns into work is a
316
+ * UI-responsiveness decision, it must be uniform across every provider, and it is
317
+ * not something a third party gets to set for the launcher it is running inside.
318
+ * Core debounces, core cancels the previous generation, and the shell streams
319
+ * whatever arrives. The fields below are consent (`runsAtRoot`), a floor the
320
+ * extension asks for its own backing service (`minQueryLength`), and limits that
321
+ * may only ever narrow the host's own (`deadlineMs`, `maxResults`).
322
+ */
323
+ export interface ActivationPolicy {
324
+ /**
325
+ * Run the command's `query` as soon as root search opens, with no query text —
326
+ * the opt-in that lets a row show live data (weather, unread count) in root.
327
+ * Off by default: opening root search must not execute every extension.
328
+ */
329
+ runsAtRoot?: boolean;
330
+ /** Do not call me with fewer characters than this. Core may still be lazier. */
331
+ minQueryLength?: number;
332
+ /** Narrower than the host's limit only; the host cap always wins. */
333
+ deadlineMs?: number;
334
+ maxResults?: number;
335
+ }
336
+
337
+ export type ManifestSetting =
338
+ | { key: string; type: "string"; title: string; description?: string; default: string }
339
+ | { key: string; type: "number"; title: string; description?: string; default: number }
340
+ | { key: string; type: "boolean"; title: string; description?: string; default: boolean }
341
+ | {
342
+ key: string;
343
+ type: "select";
344
+ title: string;
345
+ description?: string;
346
+ default: string;
347
+ options: { value: string; title: string }[];
348
+ };
349
+
350
+ /**
351
+ * **One place the extension may read** (spec 016 §2). Read-only, and the host resolves the location.
352
+ *
353
+ * Two forms, and the second exists because most apps do not name their data directory after their
354
+ * bundle id (`~/Library/Application Support/Code/`, `~/.config/op/`), so container derivation alone
355
+ * would cover almost nothing:
356
+ *
357
+ * - **`container`** — a subpath inside the container of an app from `integratesWith`. The extension
358
+ * never writes the root, so it cannot climb out of it.
359
+ * - **`root` + `path`** — a *named* root from `proto/extension-roots.json` plus a literal subpath.
360
+ * The root is a namespace selector, not the grant.
361
+ *
362
+ * `match` globs **leaf names only**. `path` may not contain wildcards or `..`. The host re-checks
363
+ * the resolved real path on every read, because a symlink inside the tree leads out of it.
364
+ */
365
+ export type FileReadGrant =
366
+ | { container: string; match?: string; root?: never; path?: never }
367
+ | { root: FileRoot; path: string; match?: string; container?: never };
368
+
369
+ /** Named roots a `files.read` grant may anchor to. The table lives in `proto/extension-roots.json`. */
370
+ export const FILE_ROOTS = ["containers", "appSupport", "preferences", "configHome", "toolHome"] as const;
371
+ export type FileRoot = (typeof FILE_ROOTS)[number];
372
+
373
+ /**
374
+ * What `path` must look like under each root — the other half of `proto/extension-roots.json`.
375
+ *
376
+ * `toolHome` resolves *at* the home directory, which every other rule here exists to prevent, and
377
+ * `dot-segment` is what buys it back: exactly one segment, and it must begin with a dot. So
378
+ * `~/.codex` is declarable and `~/Documents` is not, and the install sheet's sentence stays one a
379
+ * person can evaluate. Command-line tools keep their state in `~/.codex`, `~/.claude`, `~/.aws` —
380
+ * a convention as settled as `Library/Application Support`, and one no other root can reach.
381
+ */
382
+ export const ROOT_PATH_SHAPES: Record<FileRoot, "subpath" | "dot-segment"> = {
383
+ containers: "subpath",
384
+ appSupport: "subpath",
385
+ preferences: "subpath",
386
+ configHome: "subpath",
387
+ toolHome: "dot-segment",
388
+ };
389
+
390
+ /**
391
+ * **One stored query the extension may run.**
392
+ *
393
+ * The shape of `files.read` applied to a database: the extension declares *where* and *what to ask*
394
+ * at install time, and at runtime supplies only the values that fill the statement's `?` holes. It
395
+ * never sees a path, never sees a connection, and cannot compose SQL — exactly as `exec`'s argv
396
+ * template means it cannot compose a command line.
397
+ *
398
+ * This exists because a whole class of integration keeps its state in SQLite and nowhere else, and
399
+ * neither of the other two reaches it: `files.read` hands over bytes an extension would need a
400
+ * database engine to interpret (and the sandbox has no WebAssembly to bring one), while `exec`
401
+ * needs the app to have shipped a CLI that answers the question, which Codex — the extension that
402
+ * prompted this — does not. Handing over rows instead of bytes keeps the engine, the file handle
403
+ * and the write path on Tapcue's side of the boundary.
404
+ *
405
+ * The host opens the database **read-only** and refuses a statement that is not a single read: one
406
+ * statement, no semicolon, `SELECT`/`WITH` only, and SQLite's own `readonly` verdict on the
407
+ * prepared statement is the second door.
408
+ */
409
+ export interface QueryGrant {
410
+ /** Stable id the extension calls it by. Unique inside the manifest. */
411
+ id: string;
412
+ /** A named root, same vocabulary as `FileReadGrant`. */
413
+ root: FileRoot;
414
+ /** A literal subpath under that root, shaped by `ROOT_PATH_SHAPES`. */
415
+ path: string;
416
+ /** The database's leaf name inside that directory. One named file — no globs. */
417
+ database: string;
418
+ /** The statement, fixed here. `?` for each value supplied at call time, in order. */
419
+ sql: string;
420
+ }
421
+
422
+ /**
423
+ * **One binary the extension may run** (spec 016 §3), identified by *what it is* rather than by
424
+ * where it sits — a path is user-configurable, and "run the program at this path" is "run anything".
425
+ *
426
+ * - **`identifier` + `team`** — an external binary, checked against its code signature. The host
427
+ * resolves the path from its own search list; the extension never supplies one.
428
+ * - **`inApp` + `executable`** — a binary inside the bundle of an app from `integratesWith`. The
429
+ * bundle is the identity, so no signature declaration is needed.
430
+ *
431
+ * **`args` is a template, and it is required.** Holes are `{}`, filled positionally at call time
432
+ * from `ctx.exec.run(id, [...])`; a hole is always exactly one argv element, never split and never
433
+ * shell-interpreted. The template is what makes a later policy possible at all — "no write
434
+ * operations" is a statement about argv, and a runtime string cannot be judged. Declare one entry
435
+ * per shape you need.
436
+ *
437
+ * `protocol: "mcp"` runs the binary as a long-lived stdio MCP server instead: `ctx.exec.mcp(id)`
438
+ * hands back `listTools()` / `callTool()`, the process is reused across invocations rather than
439
+ * spawned per keystroke, and the install sheet can enumerate the tools. MCP is plain JSON-RPC over
440
+ * stdio and involves no model.
441
+ */
442
+ export interface ExecGrant {
443
+ /** Stable id the extension calls it by. Unique inside the manifest. */
444
+ id: string;
445
+ /** External form: the binary's code-signing identifier, e.g. `com.1password.op`. */
446
+ identifier?: string;
447
+ /** External form: its Team ID, e.g. `2BUA8C4S2C`. Required with `identifier`. */
448
+ team?: string;
449
+ /** Bundle form: a bundle id from `integratesWith`. */
450
+ inApp?: string;
451
+ /**
452
+ * **The binary's name** — `op`, `1password-mcp`. Required for both forms.
453
+ *
454
+ * Distinct from `id`, which names the *declaration*: one binary normally carries several, because
455
+ * each argv shape is its own entry (`op item list`, `op read`). Conflating them sends the host
456
+ * looking for a file named after the declaration, which no machine has.
457
+ *
458
+ * For the bundle form it is resolved inside `Contents/MacOS`; for the external form, on the
459
+ * host's own search path.
460
+ */
461
+ executable?: string;
462
+ /** Fixed argv, with `{}` for each value supplied at call time. Omit for `protocol: "mcp"`. */
463
+ args?: string[];
464
+ /** Speak MCP to it over stdio rather than reading stdout. */
465
+ protocol?: "mcp";
466
+ }
467
+
468
+ export interface ManifestPermissions {
469
+ http?: { hosts: string[] };
470
+ storage?: { maxBytes: number };
471
+ cache?: { maxBytes: number };
472
+ /** Where the extension may read. See `FileReadGrant`; spec 016 §2. */
473
+ files?: { read: FileReadGrant[] };
474
+ /** Which binaries it may run. See `ExecGrant`; spec 016 §3. */
475
+ exec?: ExecGrant[];
476
+ /** Which stored statements it may run. See `QueryGrant`. */
477
+ queries?: QueryGrant[];
478
+ secrets?: { names: string[] };
479
+ clipboard?: { read?: boolean; write?: boolean };
480
+ /** Tapcue's unified Recents: `record` files a produced item (scope-entry only — the host
481
+ * stamps the extension's identity and drops any action), `list` reads back this
482
+ * extension's own records (never anyone else's), `remove` drops one it recorded. */
483
+ recents?: { record?: boolean; list?: boolean; remove?: boolean };
484
+ /**
485
+ * Fill Tapcue's `workspace.roots` slot: answer, when asked, which project folders the user works
486
+ * in. Tapcue joins a relative path typed into home against them, so a root is a claim about where
487
+ * the user's files are — the host still requires each one to exist, to be a directory, and to sit
488
+ * under the user's home, and every row built from one shows the full path it landed on.
489
+ */
490
+ workspace?: { roots?: boolean };
491
+ /**
492
+ * The situation that summoned Tapcue: the frontmost app, the selection, the highlighted
493
+ * files. Invocation-scoped facts, and only those — machine-wide queries live below.
494
+ */
495
+ context?: {
496
+ frontmostApp?: boolean;
497
+ selectedText?: boolean;
498
+ selectedFiles?: boolean;
499
+ };
500
+ /**
501
+ * Enumerate the fonts installed on this machine. A platform-API read (CoreText /
502
+ * DirectWrite / fontconfig), never a filesystem walk. Includes each family's file path and
503
+ * name-table metadata: an installed extension the user granted this to is asking exactly
504
+ * "which fonts do I have, and where did this one come from".
505
+ */
506
+ fonts?: boolean;
507
+ /**
508
+ * Where the machine is. Promoted out of `context.*`: a position is true whether or not
509
+ * anyone opened the launcher, so it is a machine fact, not an invocation fact.
510
+ */
511
+ location?: { precision: "coarse" | "precise" };
512
+ /**
513
+ * The processes running on this machine. Enumeration is a platform API a sandboxed guest
514
+ * cannot reach (no child process, no `/proc`), so the host supplies it — like `fonts`.
515
+ *
516
+ * - `list` — the benign read: name, pid, cpu, memory, listening ports.
517
+ * - `command` — adds argv (`ProcessInfo.command`) to the read. Its own toggle because a command
518
+ * line routinely carries secrets; `list` without `command` is the safe default. Requires `list`.
519
+ * - `kill` — the manage effect (`kill` + `activate`), action-only. Requires `list`. It carries no
520
+ * pid scope; the host-owned confirm dialog is the boundary.
521
+ */
522
+ process?: { list?: boolean; command?: boolean; kill?: boolean };
523
+ /**
524
+ * `notify` posts a notification (unsolicited), and `save` writes one file to a user-visible
525
+ * location (Downloads) that the host — never the extension — names and sanitizes. `open` and
526
+ * `reveal` are **not** here: they are ambient action-only effects needing no grant (see
527
+ * `PermissionUnit`), so an extension does not declare them and the user does not approve them.
528
+ */
529
+ native?: { notify?: boolean; save?: boolean };
530
+ /**
531
+ * The screen (architecture §9.4). `overlay` draws and receives pointer input and grants
532
+ * no pixel access at all; `capture` reads pixels and is the Screen Recording permission
533
+ * in everything but name. A ruler asks for the first and never the second.
534
+ */
535
+ screen?: { overlay?: boolean; capture?: boolean };
536
+ /**
537
+ * A panel-hosted WebView surface (architecture §12.1). It is the deliberate escape hatch
538
+ * for UI the semantic scene vocabulary cannot express — a drawing canvas is the first one
539
+ * that needs it. Reviewed at a higher trust tier: the surface runs the extension's own
540
+ * HTML/JS, so it is not "describe, the shell renders" — it is the exception the rest of
541
+ * the UI contract is defined against. The surface still has zero network (`connect-src
542
+ * 'none'`), loads only package assets, and can never cover Tapcue's own chrome.
543
+ */
544
+ ui?: { webview?: boolean };
545
+ /**
546
+ * **Stay loaded between calls.** Without it the isolate is torn down when Tapcue is done with
547
+ * it, and that is the design rather than a memory optimization: an isolate that does not survive
548
+ * a call cannot hold anything across one. With it the isolate lives, and the runtime's phase
549
+ * check is what makes stashing a capability pointless instead of impossible.
550
+ */
551
+ background?: boolean;
552
+ /**
553
+ * **Let the manifest's `suggestionRules` take part in the suggestion band** (spec 018 §8).
554
+ *
555
+ * The one unit the extension's own code never touches: the rules are text in the manifest, and
556
+ * the *host* parses, clamps and evaluates them — no isolate is woken to produce a suggestion, and
557
+ * the extension runs only once the user picks one. Withdrawing it drops that extension's rules
558
+ * and nothing else, which is why it is a unit rather than a side effect of being installed.
559
+ */
560
+ suggestions?: { rules?: boolean };
561
+ }
562
+
563
+ /* ------------------------------------------------------------ validation --- */
564
+
565
+ export interface ManifestProblem {
566
+ path: string;
567
+ message: string;
568
+ }
569
+
570
+ /** Same grammar the shell resolves at runtime; see `scene.ts`. */
571
+ export function iconProblem(icon: unknown): string | undefined {
572
+ if (typeof icon !== "string") return "must be a string";
573
+ if (icon === "extension" || icon.startsWith("symbol:")) return undefined;
574
+ if (icon.startsWith("color:")) {
575
+ return HEX_COLOR_PATTERN.test(icon.slice("color:".length))
576
+ ? undefined
577
+ : "colour chip must be color:#RRGGBB";
578
+ }
579
+ // A specimen tile drawn in the named family — the font counterpart of a `color:` chip. The
580
+ // family is untrusted text here; the shell resolves it and falls back if it is not installed,
581
+ // exactly as it does for an unresolvable `asset:`.
582
+ if (icon.startsWith("font:")) {
583
+ return icon.slice("font:".length).trim() === ""
584
+ ? "font specimen must name a family, e.g. font:Helvetica Neue"
585
+ : undefined;
586
+ }
587
+ // The running process's app icon, resolved by the shell from the pid — no bytes cross to the
588
+ // extension. The pid is untrusted; the shell degrades to a generic glyph if it names no app.
589
+ if (icon.startsWith("process:")) {
590
+ return /^\d+$/.test(icon.slice("process:".length))
591
+ ? undefined
592
+ : "process icon must name a pid, e.g. process:1234";
593
+ }
594
+ // The site's own icon, fetched and cached by the shell. The host is untrusted text: it is a
595
+ // *name*, never a URL and never a path, so anything with a slash, a scheme, or no dot is refused
596
+ // here rather than handed to a fetcher to make sense of.
597
+ if (icon.startsWith("favicon:")) {
598
+ const host = icon.slice("favicon:".length).trim();
599
+ return /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(host)
600
+ ? undefined
601
+ : "favicon must name a host, e.g. favicon:github.com";
602
+ }
603
+ if (!icon.startsWith("asset:")) {
604
+ return 'must be "extension", "symbol:<name>", "color:#RRGGBB", "font:<family>", "process:<pid>", "favicon:<host>", or "asset:<path>"';
605
+ }
606
+ const path = icon.slice("asset:".length);
607
+ if (path === "" || path.startsWith("/") || path.split("/").includes("..")) {
608
+ return "asset path must be package-relative and must not traverse";
609
+ }
610
+ return undefined;
611
+ }
612
+
613
+ const ID_PATTERN = /^[a-z0-9]+(\.[a-z0-9-]+)+$/;
614
+ /**
615
+ * Somebody *else's* bundle id, which is theirs to case however they cased it: `com.apple.TextEdit`,
616
+ * `com.apple.Safari`, `com.microsoft.VSCode`. `ID_PATTERN` is Tapcue's house style for an
617
+ * extension's own id and was wrong here — it made every Apple app permanently un-integratable.
618
+ * What still must hold is that the string is a *name* and not a path: it is appended to
619
+ * `~/Library/Containers/` for a container grant, so no empty segment, no slash, no `..`.
620
+ */
621
+ const BUNDLE_ID_PATTERN = /^[A-Za-z0-9]+(\.[A-Za-z0-9-]+)+$/;
622
+ const COMMAND_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
623
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
624
+ const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
625
+
626
+ /**
627
+ * Validates a parsed manifest without executing any package code. Returns every
628
+ * problem it finds rather than throwing on the first, so an author sees the full
629
+ * list in one run.
630
+ */
631
+ export function validateManifest(value: unknown): ManifestProblem[] {
632
+ const problems: ManifestProblem[] = [];
633
+ const fail = (path: string, message: string) => problems.push({ path, message });
634
+
635
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
636
+ return [{ path: "", message: "manifest must be a JSON object" }];
637
+ }
638
+ const m = value as Record<string, unknown>;
639
+
640
+ if (m.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
641
+ fail("schemaVersion", `must be ${MANIFEST_SCHEMA_VERSION}`);
642
+ }
643
+
644
+ const types: PackageType[] = ["commands", "skill", "extension", "script"];
645
+ if (typeof m.type !== "string" || !types.includes(m.type as PackageType)) {
646
+ fail("type", `must be one of ${types.join(", ")}`);
647
+ }
648
+
649
+ if (typeof m.id !== "string" || !ID_PATTERN.test(m.id)) {
650
+ fail("id", "must be a reverse-domain identifier, e.g. com.example.weather");
651
+ }
652
+ for (const field of ["name", "description"] as const) {
653
+ if (typeof m[field] !== "string" || (m[field] as string).trim() === "") {
654
+ fail(field, "must be a non-empty string");
655
+ }
656
+ }
657
+ if (typeof m.version !== "string" || !SEMVER_PATTERN.test(m.version)) {
658
+ fail("version", "must be a semantic version, e.g. 1.0.0");
659
+ }
660
+ const engines = m.engines as { tapcue?: unknown } | undefined;
661
+ if (!engines || typeof engines.tapcue !== "string") {
662
+ fail("engines.tapcue", "must declare a Tapcue version range");
663
+ }
664
+
665
+ if (m.icon !== undefined) {
666
+ const problem = iconProblem(m.icon);
667
+ if (problem) fail("icon", problem);
668
+ }
669
+
670
+ if (m.type === "extension") {
671
+ if (m.runtime !== "deno") fail("runtime", 'must be "deno" for type: extension');
672
+ if (typeof m.api !== "string") fail("api", "must declare the SDK API range, e.g. 1.x");
673
+ if (typeof m.entry !== "string" || !(m.entry as string).endsWith(".js")) {
674
+ fail("entry", "must point at the bundled ES module, e.g. main.js");
675
+ }
676
+ }
677
+
678
+ const commands = m.commands;
679
+ if (!Array.isArray(commands) || commands.length === 0) {
680
+ fail("commands", "must declare at least one command");
681
+ } else {
682
+ const seen = new Set<string>();
683
+ commands.forEach((raw, index) => {
684
+ const path = `commands[${index}]`;
685
+ const c = raw as Record<string, unknown>;
686
+ if (typeof c.id !== "string" || !COMMAND_ID_PATTERN.test(c.id)) {
687
+ fail(`${path}.id`, "must be a kebab-case identifier");
688
+ } else if (seen.has(c.id)) {
689
+ fail(`${path}.id`, `duplicate command id "${c.id}"`);
690
+ } else {
691
+ seen.add(c.id);
692
+ }
693
+ if (typeof c.title !== "string" || c.title.trim() === "") fail(`${path}.title`, "required");
694
+ if (typeof c.description !== "string") fail(`${path}.description`, "required");
695
+ if (c.mode !== "view" && c.mode !== "no-view") fail(`${path}.mode`, 'must be "view" or "no-view"');
696
+ // A `no-view` command has no `query` handler, so it has nothing to schedule:
697
+ // a `queryMode` on it would be a field the host silently ignores, and a field
698
+ // the host silently ignores is a field an author will believe in.
699
+ if (c.mode === "no-view") {
700
+ if (c.queryMode !== undefined) {
701
+ fail(`${path}.queryMode`, "a no-view command has no query — omit queryMode");
702
+ }
703
+ } else if (c.queryMode !== "catalog" && c.queryMode !== "remote") {
704
+ fail(`${path}.queryMode`, 'must be "catalog" or "remote"');
705
+ }
706
+ if (c.icon !== undefined) {
707
+ const problem = iconProblem(c.icon);
708
+ if (problem) fail(`${path}.icon`, problem);
709
+ }
710
+ const activation = c.activation as ActivationPolicy | undefined;
711
+ if (c.queryMode === "remote" && !activation) {
712
+ fail(`${path}.activation`, "a remote command must declare its activation policy");
713
+ }
714
+ if (activation?.runsAtRoot && c.queryMode !== "remote") {
715
+ fail(`${path}.activation.runsAtRoot`, "only a remote command can run at root");
716
+ }
717
+ validateInput(c.input as CommandInput | undefined, `${path}.input`, fail);
718
+ const surface = c.surface as CommandSurface | undefined;
719
+ if (surface !== undefined) {
720
+ if (surface.layout !== undefined && surface.layout !== "list" && surface.layout !== "grid") {
721
+ fail(`${path}.surface.layout`, 'must be "list" or "grid"');
722
+ }
723
+ if (c.mode === "no-view") {
724
+ // A `no-view` command has no surface at all — its row *is* its action — so a layout on
725
+ // one is a field the host would ignore, which is a field an author will believe in.
726
+ fail(`${path}.surface`, "a no-view command has no surface");
727
+ }
728
+ }
729
+ const args = c.arguments;
730
+ if (args !== undefined) {
731
+ if (!Array.isArray(args)) {
732
+ fail(`${path}.arguments`, "must be an array");
733
+ } else {
734
+ if (args.length > 3) fail(`${path}.arguments`, "at most 3 arguments");
735
+ const names = new Set<string>();
736
+ args.forEach((rawArg, argIndex) => {
737
+ const a = rawArg as Record<string, unknown>;
738
+ const argPath = `${path}.arguments[${argIndex}]`;
739
+ if (typeof a.name !== "string" || !COMMAND_ID_PATTERN.test(a.name)) {
740
+ fail(`${argPath}.name`, "must be a kebab-case identifier");
741
+ } else if (names.has(a.name)) {
742
+ fail(`${argPath}.name`, `duplicate argument name "${a.name}"`);
743
+ } else {
744
+ names.add(a.name);
745
+ }
746
+ if (typeof a.title !== "string") fail(`${argPath}.title`, "required");
747
+ if (a.type !== "text") fail(`${argPath}.type`, 'must be "text"');
748
+ });
749
+ // Only the last argument may be optional: Tapcue splits trailing free
750
+ // text left to right, so an optional argument in the middle is ambiguous.
751
+ args.forEach((rawArg, argIndex) => {
752
+ const a = rawArg as Record<string, unknown>;
753
+ if (a.required !== true && argIndex < args.length - 1) {
754
+ fail(`${path}.arguments[${argIndex}]`, "only the last argument may be optional");
755
+ }
756
+ });
757
+ }
758
+ }
759
+ });
760
+ }
761
+
762
+ const integrates = validateIntegratesWith(m, fail);
763
+ validateContributes(m, integrates, fail);
764
+ validateProvides(m, fail);
765
+ for (const block of ["permissions", "optionalPermissions"] as const) {
766
+ validateReach(m[block] as ManifestPermissions | undefined, block, integrates, fail);
767
+ }
768
+
769
+ const settings = m.settings;
770
+ if (settings !== undefined) {
771
+ if (!Array.isArray(settings)) {
772
+ fail("settings", "must be an array");
773
+ } else {
774
+ const keys = new Set<string>();
775
+ settings.forEach((raw, index) => {
776
+ const s = raw as Record<string, unknown>;
777
+ const path = `settings[${index}]`;
778
+ if (typeof s.key !== "string" || s.key.trim() === "") fail(`${path}.key`, "required");
779
+ else if (keys.has(s.key)) fail(`${path}.key`, `duplicate setting key "${s.key}"`);
780
+ else keys.add(s.key);
781
+ if (typeof s.title !== "string") fail(`${path}.title`, "required");
782
+ if (s.default === undefined) fail(`${path}.default`, "every setting must declare a default");
783
+ if (s.type === "select") {
784
+ const options = s.options;
785
+ if (!Array.isArray(options) || options.length === 0) {
786
+ fail(`${path}.options`, "a select setting must declare options");
787
+ } else if (!options.some((o) => (o as { value?: unknown }).value === s.default)) {
788
+ fail(`${path}.default`, "default must be one of the declared options");
789
+ }
790
+ } else if (!["string", "number", "boolean"].includes(s.type as string)) {
791
+ fail(`${path}.type`, 'must be "string", "number", "boolean", or "select"');
792
+ }
793
+ });
794
+ }
795
+ }
796
+
797
+ validateSuggestionRules(m.suggestionRules, fail);
798
+
799
+ const required = m.permissions as ManifestPermissions | undefined;
800
+ const optional = m.optionalPermissions as ManifestPermissions | undefined;
801
+ for (const [block, permissions] of [
802
+ ["permissions", required],
803
+ ["optionalPermissions", optional],
804
+ ] as const) {
805
+ if (!permissions) continue;
806
+ if (permissions.http) {
807
+ const hosts = permissions.http.hosts;
808
+ if (!Array.isArray(hosts) || hosts.length === 0) {
809
+ fail(`${block}.http.hosts`, "must list at least one host");
810
+ } else {
811
+ hosts.forEach((host, index) => {
812
+ if (typeof host !== "string" || !HOST_PATTERN.test(host)) {
813
+ fail(`${block}.http.hosts[${index}]`, "must be a bare host name, no scheme or path");
814
+ }
815
+ });
816
+ }
817
+ }
818
+ const precision = permissions.location?.precision;
819
+ if (precision !== undefined && precision !== "coarse" && precision !== "precise") {
820
+ fail(`${block}.location.precision`, 'must be "coarse" or "precise"');
821
+ }
822
+ }
823
+
824
+ // Approval is all-or-nothing per unit, so a unit cannot be required *and*
825
+ // optional — there would be no way for the user, or the extension, to say which.
826
+ const requiredUnits = permissionUnits(required);
827
+ for (const unit of permissionUnits(optional)) {
828
+ if (requiredUnits.has(unit)) {
829
+ fail(`optionalPermissions.${unit}`, `"${unit}" is already declared as required`);
830
+ }
831
+ }
832
+
833
+ // `process.command` (argv) and `process.kill`/`activate` both operate on what the read returns:
834
+ // neither is meaningful without `process.list`, and the runtime keys the whole `ctx.process`
835
+ // object on the read being granted, so the type's `WritableProcessManager extends
836
+ // ReadonlyProcessManager` only holds if `list` is always there when the object is.
837
+ const declaredUnits = new Set([...requiredUnits, ...permissionUnits(optional)]);
838
+ // Rules with no unit behind them are text the host reads and throws away (spec 018 §8), and a
839
+ // field the host silently ignores is a field an author will believe in. The host says the same
840
+ // thing again at load, in its log — this one is just early enough to fix before shipping.
841
+ if (m.suggestionRules !== undefined && !declaredUnits.has("suggestions.rules")) {
842
+ fail("suggestionRules", 'needs the "suggestions.rules" permission, or nothing loads them');
843
+ }
844
+ for (const unit of ["process.command", "process.kill"] as const) {
845
+ if (declaredUnits.has(unit) && !declaredUnits.has("process.list")) {
846
+ const block = requiredUnits.has(unit) ? "permissions" : "optionalPermissions";
847
+ fail(`${block}.process`, `"${unit}" needs "process.list" — you cannot manage what you cannot read`);
848
+ }
849
+ }
850
+
851
+ return problems;
852
+ }
853
+
854
+ /** The names from the capability table that this permission block grants. */
855
+ export function permissionUnits(permissions: ManifestPermissions | undefined): Set<string> {
856
+ const units = new Set<string>();
857
+ if (!permissions) return units;
858
+ for (const spec of PERMISSION_UNITS) {
859
+ const group = (permissions as Record<string, unknown>)[spec.group];
860
+ if (group === undefined || group === null || group === false) continue;
861
+ // A group with no key *is* the unit: its presence grants it (`http`, `storage`, `location`),
862
+ // except `http`, whose empty host list grants nothing to reach.
863
+ // A list-valued unit grants only what is *in* the list: an empty `exec` reaches no binary and
864
+ // an empty `files.read` reaches no file, exactly as an empty `http.hosts` reaches no server.
865
+ if (spec.kind === "binaries") {
866
+ if (Array.isArray(group) && group.length > 0) units.add(spec.unit);
867
+ continue;
868
+ }
869
+ if (spec.kind === "paths") {
870
+ const list = (group as Record<string, unknown>)[spec.key!];
871
+ if (Array.isArray(list) && list.length > 0) units.add(spec.unit);
872
+ continue;
873
+ }
874
+ if (spec.key === null) {
875
+ if (spec.unit === "http" && (permissions.http?.hosts?.length ?? 0) === 0) continue;
876
+ units.add(spec.unit);
877
+ continue;
878
+ }
879
+ if ((group as Record<string, unknown>)[spec.key] === true) units.add(spec.unit);
880
+ }
881
+ return units;
882
+ }
883
+
884
+ /**
885
+ * What the extension actually gets: everything `required` (it could not have been
886
+ * enabled otherwise) plus whichever `optional` units the user approved.
887
+ *
888
+ * `approvedOptional` is intersected with the declaration, never unioned: an
889
+ * over-generous approval cannot widen a manifest.
890
+ */
891
+ export function effectiveGrants(
892
+ manifest: ExtensionManifest,
893
+ approvedOptional?: ManifestPermissions,
894
+ ): ManifestPermissions {
895
+ const optional = intersectPermissions(
896
+ manifest.optionalPermissions,
897
+ approvedOptional ?? manifest.optionalPermissions,
898
+ );
899
+ return mergePermissions(manifest.permissions ?? {}, optional);
900
+ }
901
+
902
+ type PermissionRecord = Record<string, any>;
903
+
904
+ /** The longest `pattern` a command may declare — see `CommandInput.pattern`. */
905
+ const MAX_INPUT_PATTERN_LENGTH = 200;
906
+
907
+ /**
908
+ * Validates a command's `input` block at install time, where a bad one costs nothing.
909
+ *
910
+ * The pattern is checked for two things and neither is a nicety. It must **compile**, because a
911
+ * pattern the host cannot build is a suggestion that silently never appears; and it must be
912
+ * **short**, because it is a guest-authored regular expression that the launcher's own process will
913
+ * run — the host separately caps the text it is run against, and between the two, catastrophic
914
+ * backtracking has nowhere interesting to go.
915
+ */
916
+ function validateInput(
917
+ input: CommandInput | undefined,
918
+ path: string,
919
+ fail: (path: string, message: string) => void,
920
+ ): void {
921
+ if (input === undefined) return;
922
+ if (typeof input !== "object" || input === null) {
923
+ fail(path, "must be an object");
924
+ return;
925
+ }
926
+ if (input.accepts !== undefined) {
927
+ if (!Array.isArray(input.accepts) || input.accepts.length === 0) {
928
+ fail(`${path}.accepts`, "must be a non-empty array");
929
+ } else {
930
+ for (const type of input.accepts) {
931
+ // Closed, and it stays closed until the shell can actually find a value of the new type.
932
+ if (type !== "color") fail(`${path}.accepts`, `unknown subject type "${String(type)}"`);
933
+ }
934
+ }
935
+ }
936
+ if (input.pattern !== undefined) {
937
+ if (typeof input.pattern !== "string" || input.pattern.length === 0) {
938
+ fail(`${path}.pattern`, "must be a non-empty string");
939
+ } else if (input.pattern.length > MAX_INPUT_PATTERN_LENGTH) {
940
+ fail(`${path}.pattern`, `at most ${MAX_INPUT_PATTERN_LENGTH} characters`);
941
+ } else {
942
+ try {
943
+ new RegExp(input.pattern);
944
+ } catch {
945
+ fail(`${path}.pattern`, "is not a valid regular expression");
946
+ }
947
+ }
948
+ }
949
+ if (input.autocomplete !== undefined && typeof input.autocomplete !== "boolean") {
950
+ fail(`${path}.autocomplete`, "must be a boolean");
951
+ }
952
+ if (input.autocomplete && input.accepts === undefined && input.pattern === undefined) {
953
+ // Otherwise it reads as "suggest me something" with nothing said about what — and the host
954
+ // would have to guess, which is the one thing this declaration exists to avoid.
955
+ fail(path, "autocomplete needs `accepts` or `pattern` to know what to offer");
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Combine the boolean units of two blocks with `combine` — `||` when merging required with
961
+ * approved-optional, `&&` when intersecting a declaration with an approval.
962
+ *
963
+ * Walks the unit table rather than naming each field, so adding a boolean unit is one row there.
964
+ * A group with nothing granted inside it is dropped entirely, which is what makes an absent
965
+ * capability object mean "denied" rather than "empty".
966
+ */
967
+ function combineBooleans(
968
+ a: ManifestPermissions,
969
+ b: ManifestPermissions,
970
+ into: ManifestPermissions,
971
+ combine: (left: boolean, right: boolean) => boolean,
972
+ requireBothGroups: boolean,
973
+ ): void {
974
+ const left = a as PermissionRecord;
975
+ const right = b as PermissionRecord;
976
+ const target = into as PermissionRecord;
977
+
978
+ for (const spec of FLAG_PERMISSION_UNITS) {
979
+ if (combine(Boolean(left[spec.group]), Boolean(right[spec.group]))) target[spec.group] = true;
980
+ }
981
+
982
+ for (const [group, keys] of BOOLEAN_PERMISSION_GROUPS) {
983
+ // Intersection needs the group present on *both* sides; a union needs only one.
984
+ if (requireBothGroups && !(left[group] && right[group])) continue;
985
+ const value: Record<string, boolean> = {};
986
+ let granted = false;
987
+ for (const key of keys) {
988
+ const on = combine(Boolean(left[group]?.[key]), Boolean(right[group]?.[key]));
989
+ value[key] = on;
990
+ granted ||= on;
991
+ }
992
+ if (granted) target[group] = value;
993
+ }
994
+ }
995
+
996
+ function mergePermissions(a: ManifestPermissions, b: ManifestPermissions): ManifestPermissions {
997
+ const merged: ManifestPermissions = {};
998
+ const hosts = [...(a.http?.hosts ?? []), ...(b.http?.hosts ?? [])];
999
+ if (hosts.length > 0) merged.http = { hosts };
1000
+ if (a.storage || b.storage) {
1001
+ merged.storage = { maxBytes: Math.max(a.storage?.maxBytes ?? 0, b.storage?.maxBytes ?? 0) };
1002
+ }
1003
+ if (a.cache || b.cache) {
1004
+ merged.cache = { maxBytes: Math.max(a.cache?.maxBytes ?? 0, b.cache?.maxBytes ?? 0) };
1005
+ }
1006
+ const names = [...(a.secrets?.names ?? []), ...(b.secrets?.names ?? [])];
1007
+ if (names.length > 0) merged.secrets = { names };
1008
+ // The two list-valued units (spec 016) concatenate, required block first. Order matters and is
1009
+ // part of the contract: a `files.read` grant's **index** is the only handle the guest has on it,
1010
+ // so required-then-optional has to be the same order on both sides of the boundary.
1011
+ const read = [...(a.files?.read ?? []), ...(b.files?.read ?? [])];
1012
+ if (read.length > 0) merged.files = { read };
1013
+ const exec = [...(a.exec ?? []), ...(b.exec ?? [])];
1014
+ if (exec.length > 0) merged.exec = exec;
1015
+ const queries = [...(a.queries ?? []), ...(b.queries ?? [])];
1016
+ if (queries.length > 0) merged.queries = queries;
1017
+ if (a.location || b.location) merged.location = a.location ?? b.location;
1018
+ combineBooleans(a, b, merged, (left, right) => left || right, false);
1019
+ return merged;
1020
+ }
1021
+
1022
+ /** Effective optional grant = declaration ∩ approval. Never wider than either. */
1023
+ export function intersectPermissions(
1024
+ declared: ManifestPermissions | undefined,
1025
+ approved: ManifestPermissions | undefined,
1026
+ ): ManifestPermissions {
1027
+ if (!declared || !approved) return {};
1028
+ const effective: ManifestPermissions = {};
1029
+
1030
+ if (declared.http && approved.http) {
1031
+ const hosts = approved.http.hosts.filter((host) => declared.http!.hosts.includes(host));
1032
+ if (hosts.length > 0) effective.http = { hosts };
1033
+ }
1034
+ if (declared.storage && approved.storage) {
1035
+ effective.storage = { maxBytes: Math.min(declared.storage.maxBytes, approved.storage.maxBytes) };
1036
+ }
1037
+ if (declared.cache && approved.cache) {
1038
+ effective.cache = { maxBytes: Math.min(declared.cache.maxBytes, approved.cache.maxBytes) };
1039
+ }
1040
+ if (declared.secrets && approved.secrets) {
1041
+ const names = approved.secrets.names.filter((name) => declared.secrets!.names.includes(name));
1042
+ if (names.length > 0) effective.secrets = { names };
1043
+ }
1044
+ // Approval narrows, never widens: an entry the manifest never declared cannot be approved into
1045
+ // existence. Compared structurally, since these carry no natural key the user picks from — the
1046
+ // switch is per *unit* (§7.1), so in practice approval is the whole list or none of it.
1047
+ if (declared.files?.read && approved.files?.read) {
1048
+ const read = declared.files.read.filter((grant) =>
1049
+ approved.files!.read.some((other) => JSON.stringify(other) === JSON.stringify(grant)),
1050
+ );
1051
+ if (read.length > 0) effective.files = { read };
1052
+ }
1053
+ if (declared.queries && approved.queries) {
1054
+ const queries = declared.queries.filter((grant) =>
1055
+ approved.queries!.some((other) => JSON.stringify(other) === JSON.stringify(grant)),
1056
+ );
1057
+ if (queries.length > 0) effective.queries = queries;
1058
+ }
1059
+ if (declared.exec && approved.exec) {
1060
+ const exec = declared.exec.filter((grant) =>
1061
+ approved.exec!.some((other) => JSON.stringify(other) === JSON.stringify(grant)),
1062
+ );
1063
+ if (exec.length > 0) effective.exec = exec;
1064
+ }
1065
+ if (declared.location && approved.location) {
1066
+ // The narrower precision wins.
1067
+ effective.location = {
1068
+ precision:
1069
+ declared.location.precision === "coarse" || approved.location.precision === "coarse"
1070
+ ? "coarse"
1071
+ : "precise",
1072
+ };
1073
+ }
1074
+ combineBooleans(declared, approved, effective, (left, right) => left && right, true);
1075
+ return effective;
1076
+ }
1077
+
1078
+ /** Every `@key` reference in the manifest, for catalog completeness checks. */
1079
+ export function manifestCatalogKeys(manifest: ExtensionManifest): string[] {
1080
+ const keys: string[] = [];
1081
+ const take = (value: string | undefined) => {
1082
+ if (value?.startsWith("@")) keys.push(value.slice(1));
1083
+ };
1084
+ take(manifest.name);
1085
+ take(manifest.description);
1086
+ for (const command of manifest.commands) {
1087
+ take(command.title);
1088
+ take(command.description);
1089
+ for (const argument of command.arguments ?? []) {
1090
+ take(argument.title);
1091
+ take(argument.placeholder);
1092
+ }
1093
+ }
1094
+ for (const setting of manifest.settings ?? []) {
1095
+ take(setting.title);
1096
+ take(setting.description);
1097
+ if (setting.type === "select") for (const option of setting.options) take(option.title);
1098
+ }
1099
+ return keys;
1100
+ }
1101
+
1102
+ export function settingsDefaults(manifest: ExtensionManifest): Record<string, JsonValue> {
1103
+ const defaults: Record<string, JsonValue> = {};
1104
+ for (const setting of manifest.settings ?? []) {
1105
+ defaults[setting.key] = setting.default;
1106
+ }
1107
+ return defaults;
1108
+ }
1109
+
1110
+ /**
1111
+ * What a proposed setting value is *worth* — the one place `ctx.settings.set` and the Preferences
1112
+ * field are reconciled against the manifest.
1113
+ *
1114
+ * Returns the value coerced to the key's declared type, or `undefined` when the manifest has no
1115
+ * such key or the value cannot be that type. Undefined means **leave the setting alone**: a write
1116
+ * the manifest does not describe is not a new setting, and a `select` value outside its options is
1117
+ * not a new option. Declaring a setting is what makes it exist; writing one only moves it.
1118
+ *
1119
+ * `"5"` for a `number` coerces rather than fails, because the two writers disagree about strings
1120
+ * by nature: a rendered `<select>` hands back option values, which are text.
1121
+ */
1122
+ export function coerceSetting(
1123
+ manifest: ExtensionManifest,
1124
+ key: string,
1125
+ value: JsonValue,
1126
+ ): JsonValue | undefined {
1127
+ const setting = (manifest.settings ?? []).find((s) => s.key === key);
1128
+ if (setting === undefined) return undefined;
1129
+ switch (setting.type) {
1130
+ case "boolean":
1131
+ if (typeof value === "boolean") return value;
1132
+ if (value === "true") return true;
1133
+ if (value === "false") return false;
1134
+ return undefined;
1135
+ case "number": {
1136
+ const n = typeof value === "number" ? value : Number(value);
1137
+ return Number.isFinite(n) ? n : undefined;
1138
+ }
1139
+ case "select": {
1140
+ const text = typeof value === "string" ? value : String(value);
1141
+ return setting.options.some((option) => option.value === text) ? text : undefined;
1142
+ }
1143
+ case "string":
1144
+ return typeof value === "string" ? value : undefined;
1145
+ }
1146
+ }
1147
+
1148
+ /** The id a guest item is namespaced to before it enters core state. */
1149
+ export function namespacedItemId(extensionId: string, commandId: string, guestId: string): string {
1150
+ return `extension:${extensionId}:${commandId}:${guestId}`;
1151
+ }
1152
+
1153
+ /** Extension-declared scope types must live under this prefix. */
1154
+ export function scopeTypePrefix(extensionId: string): string {
1155
+ return `ext.${extensionId}.`;
1156
+ }
1157
+
1158
+ /**
1159
+ * `contributes[]` — a native scope, a required subject predicate, and a title.
1160
+ *
1161
+ * Every rejection here is a rejection the installer must make too, and the reason they are
1162
+ * rejections rather than ignored keys is the trap this whole mechanism sits in: a `when` field
1163
+ * the host does not understand, silently dropped, is a predicate that matches **everything**.
1164
+ * An author would ship it, the installer would accept it, and the extension would be called for
1165
+ * every app on the machine. So an unknown field fails, loudly, before installation.
1166
+ */
1167
+ /**
1168
+ * `provides[]` — which slot, and the unit that grants it.
1169
+ *
1170
+ * Both halves are checked against each other here, as `contributes` checks its handler: a provision
1171
+ * with no permission unit is an answer nobody may give, and a unit with no provision is an approval
1172
+ * the user was asked for and nothing will ever use. Neither is a shrug — the install sheet reads
1173
+ * both, and a sheet that lists a permission with no visible purpose teaches people to stop reading
1174
+ * it.
1175
+ */
1176
+ function validateProvides(
1177
+ m: Record<string, unknown>,
1178
+ fail: (path: string, message: string) => void,
1179
+ ): void {
1180
+ const declaresWorkspaceRoots = (block: unknown): boolean =>
1181
+ (block as ManifestPermissions | undefined)?.workspace?.roots === true;
1182
+ const granted = declaresWorkspaceRoots(m.permissions) || declaresWorkspaceRoots(m.optionalPermissions);
1183
+
1184
+ const provides = m.provides;
1185
+ if (provides === undefined) {
1186
+ if (granted) {
1187
+ fail("permissions.workspace.roots", "grants a slot nothing fills — declare it in provides[]");
1188
+ }
1189
+ return;
1190
+ }
1191
+ if (!Array.isArray(provides)) {
1192
+ fail("provides", "must be an array");
1193
+ return;
1194
+ }
1195
+ const ids = new Set<string>();
1196
+ const filled = new Set<string>();
1197
+ provides.forEach((raw, index) => {
1198
+ const path = `provides[${index}]`;
1199
+ const entry = raw as Record<string, unknown>;
1200
+ if (typeof entry?.id !== "string" || !COMMAND_ID_PATTERN.test(entry.id)) {
1201
+ fail(`${path}.id`, "must be a kebab-case identifier");
1202
+ } else if (ids.has(entry.id)) {
1203
+ fail(`${path}.id`, `duplicate provision id "${entry.id}"`);
1204
+ } else {
1205
+ ids.add(entry.id);
1206
+ }
1207
+ if (typeof entry?.slot !== "string" || !(SLOT_TYPES as readonly string[]).includes(entry.slot)) {
1208
+ fail(`${path}.slot`, `must be one of ${SLOT_TYPES.join(", ")}`);
1209
+ } else {
1210
+ filled.add(entry.slot);
1211
+ }
1212
+ if (typeof entry?.title !== "string" || entry.title.trim() === "") {
1213
+ fail(`${path}.title`, "must say what fills the slot, for the install sheet");
1214
+ }
1215
+ });
1216
+ if (filled.has("workspace.roots") && !granted) {
1217
+ fail("provides", "filling workspace.roots needs the workspace.roots permission");
1218
+ }
1219
+ if (granted && !filled.has("workspace.roots")) {
1220
+ fail("permissions.workspace.roots", "grants a slot nothing fills — declare it in provides[]");
1221
+ }
1222
+ }
1223
+
1224
+ function validateContributes(
1225
+ m: Record<string, unknown>,
1226
+ integrates: string[],
1227
+ fail: (path: string, message: string) => void,
1228
+ ): void {
1229
+ const contributes = m.contributes;
1230
+ if (contributes === undefined) return;
1231
+ if (!Array.isArray(contributes)) {
1232
+ fail("contributes", "must be an array");
1233
+ return;
1234
+ }
1235
+ const commandIds = new Set(
1236
+ (Array.isArray(m.commands) ? m.commands : [])
1237
+ .map((raw) => (raw as Record<string, unknown>)?.id)
1238
+ .filter((id): id is string => typeof id === "string"),
1239
+ );
1240
+ const seen = new Set<string>();
1241
+ contributes.forEach((raw, index) => {
1242
+ const path = `contributes[${index}]`;
1243
+ const c = raw as Record<string, unknown>;
1244
+ if (typeof c.id !== "string" || !COMMAND_ID_PATTERN.test(c.id)) {
1245
+ fail(`${path}.id`, "must be a kebab-case identifier");
1246
+ } else if (seen.has(c.id)) {
1247
+ fail(`${path}.id`, `duplicate contribution id "${c.id}"`);
1248
+ } else if (commandIds.has(c.id)) {
1249
+ fail(`${path}.id`, `"${c.id}" is already a command id`);
1250
+ } else {
1251
+ seen.add(c.id);
1252
+ }
1253
+ if (typeof c.title !== "string" || c.title.trim() === "") {
1254
+ fail(`${path}.title`, "required — it is what the install sheet shows");
1255
+ }
1256
+ if (typeof c.scope !== "string" || !isContributableScopeType(c.scope)) {
1257
+ fail(
1258
+ `${path}.scope`,
1259
+ `must be one of ${CONTRIBUTABLE_SCOPE_TYPES.join(", ")} — a scope an extension may own is declared in code, not here`,
1260
+ );
1261
+ return;
1262
+ }
1263
+ const fields = CONTRIBUTION_MATCH_FIELDS[c.scope as ContributableScopeType];
1264
+ const when = c.when;
1265
+ if (typeof when !== "object" || when === null || Array.isArray(when)) {
1266
+ fail(`${path}.when`, "must be an object naming which subjects match");
1267
+ return;
1268
+ }
1269
+ const entries = Object.entries(when as Record<string, unknown>);
1270
+ if (entries.length === 0) {
1271
+ fail(
1272
+ `${path}.when`,
1273
+ `must name at least one of ${fields.join(", ")} — a contribution to every subject of a scope is not declarable`,
1274
+ );
1275
+ }
1276
+ for (const [field, values] of entries) {
1277
+ const fieldPath = `${path}.when.${field}`;
1278
+ if (!fields.includes(field)) {
1279
+ fail(fieldPath, `"${c.scope}" subjects have no matchable "${field}"; try ${fields.join(", ")}`);
1280
+ continue;
1281
+ }
1282
+ if (!Array.isArray(values) || values.length === 0) {
1283
+ fail(fieldPath, "must be a non-empty array of values");
1284
+ continue;
1285
+ }
1286
+ values.forEach((value, valueIndex) => {
1287
+ if (typeof value !== "string" || value.trim() === "") {
1288
+ fail(`${fieldPath}[${valueIndex}]`, "must be a non-empty string");
1289
+ return;
1290
+ }
1291
+ // One declaration, four uses (spec 016 §1): an app named here must be named at the top
1292
+ // level too, so the install sheet has one list to show and every other reach — the
1293
+ // container to read, the bundle binary to run, the scheme to open — resolves against it.
1294
+ if (field === "bundleId" && !integrates.includes(value)) {
1295
+ fail(
1296
+ `${fieldPath}[${valueIndex}]`,
1297
+ `"${value}" is not in integratesWith — declare the apps this extension works with there`,
1298
+ );
1299
+ }
1300
+ });
1301
+ }
1302
+ });
1303
+ }
1304
+
1305
+ /** The most rule text a manifest may carry — see `ExtensionManifest.suggestionRules`. */
1306
+ const MAX_SUGGESTION_RULES_LENGTH = 8_000;
1307
+
1308
+ /**
1309
+ * `suggestionRules` — shape and size only.
1310
+ *
1311
+ * **The grammar is deliberately not checked here.** There is exactly one parser for the rule DSL
1312
+ * and it lives in the host (spec 018 §8), so a second, weaker reading of the same text on this side
1313
+ * would only be able to disagree with it. What this side can say without owning the grammar is that
1314
+ * the field is text, that there is some, and that there is not so much of it that parsing it at
1315
+ * every launch stops being free.
1316
+ */
1317
+ function validateSuggestionRules(
1318
+ value: unknown,
1319
+ fail: (path: string, message: string) => void,
1320
+ ): void {
1321
+ if (value === undefined) return;
1322
+ let text: string;
1323
+ if (typeof value === "string") {
1324
+ text = value;
1325
+ } else if (Array.isArray(value)) {
1326
+ const bad = value.findIndex((line) => typeof line !== "string");
1327
+ if (bad >= 0) {
1328
+ fail(`suggestionRules[${bad}]`, "must be a string");
1329
+ return;
1330
+ }
1331
+ text = value.join("\n");
1332
+ } else {
1333
+ fail("suggestionRules", "must be rule text, or an array of lines");
1334
+ return;
1335
+ }
1336
+ if (text.trim() === "") fail("suggestionRules", "must not be empty");
1337
+ if (text.length > MAX_SUGGESTION_RULES_LENGTH) {
1338
+ fail("suggestionRules", `must be at most ${MAX_SUGGESTION_RULES_LENGTH} characters`);
1339
+ }
1340
+ }
1341
+
1342
+ /** `integratesWith` — bundle identifiers, unique, and the anchor every other reach resolves against. */
1343
+ function validateIntegratesWith(
1344
+ m: Record<string, unknown>,
1345
+ fail: (path: string, message: string) => void,
1346
+ ): string[] {
1347
+ const raw = m.integratesWith;
1348
+ if (raw === undefined) return [];
1349
+ if (!Array.isArray(raw)) {
1350
+ fail("integratesWith", "must be an array of bundle identifiers");
1351
+ return [];
1352
+ }
1353
+ const seen = new Set<string>();
1354
+ raw.forEach((value, index) => {
1355
+ if (typeof value !== "string" || !BUNDLE_ID_PATTERN.test(value)) {
1356
+ fail(`integratesWith[${index}]`, "must be a reverse-domain bundle identifier");
1357
+ } else if (seen.has(value)) {
1358
+ fail(`integratesWith[${index}]`, `duplicate "${value}"`);
1359
+ } else {
1360
+ seen.add(value);
1361
+ }
1362
+ });
1363
+ return [...seen];
1364
+ }
1365
+
1366
+ /**
1367
+ * `files.read` and `exec` — the two grants that reach outside the sandbox (spec 016).
1368
+ *
1369
+ * Every problem here is a rejection rather than an ignored key, for the reason §11.3's `when` is:
1370
+ * these are the blocks whose failure mode is **widening**. A dropped `path` is a grant on the whole
1371
+ * root; a dropped `args` template is an unconstrained argv.
1372
+ */
1373
+ /**
1374
+ * A `path` under a named root, judged by that root's shape.
1375
+ *
1376
+ * `subpath` keeps spec 016's rule, bare root included: a grant on the whole root is legal and left
1377
+ * to review, but it has to be *written*, so the install sheet says "every app's data" rather than
1378
+ * printing a path. `dot-segment` has no bare form at all — the root is the home directory, and the
1379
+ * one dot-segment is the entire reason it is declarable.
1380
+ */
1381
+ function validateRootPath(
1382
+ root: FileRoot,
1383
+ value: unknown,
1384
+ path: string,
1385
+ fail: (path: string, message: string) => void,
1386
+ ): void {
1387
+ if (ROOT_PATH_SHAPES[root] === "dot-segment") {
1388
+ if (typeof value !== "string" || value.trim() === "") {
1389
+ fail(path, `${root} needs a dot-directory, e.g. ".codex"`);
1390
+ return;
1391
+ }
1392
+ if (value.includes("/") || value.includes("\\")) {
1393
+ fail(path, `${root} reaches exactly one directory — ".codex", not ".codex/sessions"`);
1394
+ return;
1395
+ }
1396
+ if (!value.startsWith(".") || value === "." || value === "..") {
1397
+ fail(path, `${root} reaches a tool's dot-directory — the name must begin with a dot`);
1398
+ }
1399
+ return;
1400
+ }
1401
+ if (typeof value !== "string" || value.trim() === "") {
1402
+ if (value !== undefined) fail(path, "must be a non-empty subpath, or omitted");
1403
+ return;
1404
+ }
1405
+ validateSubpath(value, path, fail);
1406
+ }
1407
+
1408
+ /**
1409
+ * The statement a `files.query` grant fixes at install time.
1410
+ *
1411
+ * Rejected rather than sanitized, and checked here only as the *first* door: the host prepares the
1412
+ * statement against a read-only connection and takes SQLite's own `readonly` verdict as the second.
1413
+ * A reader that has to guess what a string does is a reader that will eventually guess wrong, which
1414
+ * is why the shape is this narrow — one statement, one leading keyword, holes that are only `?`.
1415
+ */
1416
+ function validateReadOnlySql(
1417
+ value: unknown,
1418
+ path: string,
1419
+ fail: (path: string, message: string) => void,
1420
+ ): void {
1421
+ if (typeof value !== "string" || value.trim() === "") {
1422
+ fail(path, "must be a SELECT statement");
1423
+ return;
1424
+ }
1425
+ const sql = value.trim();
1426
+ if (sql.replace(/;\s*$/, "").includes(";")) {
1427
+ fail(path, "is one statement — a semicolon can only end it");
1428
+ return;
1429
+ }
1430
+ if (!/^(select|with)\b/i.test(sql)) {
1431
+ fail(path, "must begin with SELECT or WITH — a query grant cannot write");
1432
+ }
1433
+ if (/\b(attach|pragma)\b/i.test(sql)) {
1434
+ fail(path, "cannot ATTACH or PRAGMA — those reach outside the database that was declared");
1435
+ }
1436
+ if (/\$[A-Za-z_]|[:@][A-Za-z_]/.test(sql)) {
1437
+ fail(path, "takes positional ? holes only, so the values cannot be named or reordered");
1438
+ }
1439
+ }
1440
+
1441
+ function validateReach(
1442
+ permissions: ManifestPermissions | undefined,
1443
+ block: string,
1444
+ integrates: string[],
1445
+ fail: (path: string, message: string) => void,
1446
+ ): void {
1447
+ if (!permissions) return;
1448
+
1449
+ const reads = permissions.files?.read;
1450
+ if (permissions.files !== undefined) {
1451
+ if (!Array.isArray(reads) || reads.length === 0) {
1452
+ fail(`${block}.files.read`, "must be a non-empty array of places to read");
1453
+ } else {
1454
+ reads.forEach((grant, index) => {
1455
+ const path = `${block}.files.read[${index}]`;
1456
+ const g = grant as unknown as Record<string, unknown>;
1457
+ const hasContainer = typeof g.container === "string";
1458
+ const hasRoot = typeof g.root === "string";
1459
+ if (hasContainer === hasRoot) {
1460
+ fail(path, "declare exactly one of container (an app you integrate with) or root (a named root)");
1461
+ return;
1462
+ }
1463
+ if (hasContainer) {
1464
+ if (integrates.length === 0) {
1465
+ fail(path, "container reads resolve inside an app's bundle container — declare it in integratesWith");
1466
+ }
1467
+ validateSubpath(g.container as string, `${path}.container`, fail);
1468
+ } else {
1469
+ if (!(FILE_ROOTS as readonly string[]).includes(g.root as string)) {
1470
+ fail(`${path}.root`, `must be one of ${FILE_ROOTS.join(", ")}`);
1471
+ return;
1472
+ }
1473
+ validateRootPath(g.root as FileRoot, g.path, `${path}.path`, fail);
1474
+ }
1475
+ if (g.match !== undefined && (typeof g.match !== "string" || g.match.includes("/"))) {
1476
+ fail(`${path}.match`, "globs leaf names only — it cannot contain a slash");
1477
+ }
1478
+ });
1479
+ }
1480
+ }
1481
+
1482
+ const queries = permissions.queries;
1483
+ if (queries !== undefined) {
1484
+ if (!Array.isArray(queries) || queries.length === 0) {
1485
+ fail(`${block}.queries`, "must be a non-empty array of stored queries");
1486
+ } else {
1487
+ const ids = new Set<string>();
1488
+ queries.forEach((grant, index) => {
1489
+ const path = `${block}.queries[${index}]`;
1490
+ const g = grant as unknown as Record<string, unknown>;
1491
+ if (typeof g.id !== "string" || !COMMAND_ID_PATTERN.test(g.id)) {
1492
+ fail(`${path}.id`, "must be a kebab-case identifier");
1493
+ } else if (ids.has(g.id)) {
1494
+ fail(`${path}.id`, `duplicate query id "${g.id}"`);
1495
+ } else {
1496
+ ids.add(g.id);
1497
+ }
1498
+ if (!(FILE_ROOTS as readonly string[]).includes(g.root as string)) {
1499
+ fail(`${path}.root`, `must be one of ${FILE_ROOTS.join(", ")}`);
1500
+ } else {
1501
+ validateRootPath(g.root as FileRoot, g.path, `${path}.path`, fail);
1502
+ }
1503
+ if (typeof g.database !== "string" || g.database.trim() === "") {
1504
+ fail(`${path}.database`, "must name one database file");
1505
+ } else if (g.database.includes("/") || g.database.includes("\\") || g.database.includes("..")) {
1506
+ fail(`${path}.database`, "is a leaf name inside the declared folder — no separators, no ..");
1507
+ }
1508
+ validateReadOnlySql(g.sql, `${path}.sql`, fail);
1509
+ });
1510
+ }
1511
+ }
1512
+
1513
+ const execs = permissions.exec;
1514
+ if (execs !== undefined) {
1515
+ if (!Array.isArray(execs) || execs.length === 0) {
1516
+ fail(`${block}.exec`, "must be a non-empty array of binaries");
1517
+ return;
1518
+ }
1519
+ const ids = new Set<string>();
1520
+ execs.forEach((grant, index) => {
1521
+ const path = `${block}.exec[${index}]`;
1522
+ const g = grant as unknown as Record<string, unknown>;
1523
+ if (typeof g.id !== "string" || !COMMAND_ID_PATTERN.test(g.id)) {
1524
+ fail(`${path}.id`, "must be a kebab-case identifier");
1525
+ } else if (ids.has(g.id)) {
1526
+ fail(`${path}.id`, `duplicate exec id "${g.id}"`);
1527
+ } else {
1528
+ ids.add(g.id);
1529
+ }
1530
+ const external = typeof g.identifier === "string";
1531
+ const inApp = typeof g.inApp === "string";
1532
+ if (external === inApp) {
1533
+ fail(path, "declare exactly one of identifier+team (an external binary) or inApp+executable");
1534
+ } else if (external) {
1535
+ if (typeof g.team !== "string" || g.team.trim() === "") {
1536
+ fail(`${path}.team`, "an external binary is identified by its signature — team is required");
1537
+ }
1538
+ } else if (!integrates.includes(g.inApp as string)) {
1539
+ fail(`${path}.inApp`, `"${String(g.inApp)}" is not in integratesWith`);
1540
+ }
1541
+ // Required for *both* forms: `id` names the declaration, never the file, and one binary
1542
+ // normally carries several declarations.
1543
+ if (typeof g.executable !== "string" || g.executable.includes("/")) {
1544
+ fail(`${path}.executable`, 'must name the binary, e.g. "op"');
1545
+ }
1546
+ if (g.protocol !== undefined && g.protocol !== "mcp") {
1547
+ fail(`${path}.protocol`, 'the only protocol is "mcp"');
1548
+ }
1549
+ if (g.protocol === "mcp") {
1550
+ if (g.args !== undefined) fail(`${path}.args`, "an mcp binary is spoken to, not argv-invoked");
1551
+ } else if (!Array.isArray(g.args) || g.args.some((arg: unknown) => typeof arg !== "string")) {
1552
+ // Required, and this is the load-bearing rule: a later policy ("no write operations") is a
1553
+ // statement about argv, and there is nothing to state it about if the shape is only known
1554
+ // at call time.
1555
+ fail(`${path}.args`, "required — declare the argv template, with {} for each value supplied at call time");
1556
+ }
1557
+ });
1558
+ }
1559
+ }
1560
+
1561
+ /** A subpath inside a host-chosen root: no wildcards, no climbing, no absolute form. */
1562
+ function validateSubpath(value: string, path: string, fail: (path: string, message: string) => void): void {
1563
+ if (value.trim() === "") {
1564
+ fail(path, "must be a non-empty subpath");
1565
+ } else if (value.startsWith("/") || value.startsWith("~")) {
1566
+ fail(path, "is relative to a root the host chooses — it cannot be absolute");
1567
+ } else if (value.split("/").some((segment) => segment === "..")) {
1568
+ fail(path, 'cannot contain ".."');
1569
+ } else if (/[*?[\]]/.test(value)) {
1570
+ fail(path, "cannot contain wildcards — glob leaf names with `match` instead");
1571
+ }
1572
+ }