@sdxc/spec 0.0.0-pre.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.
Files changed (77) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +924 -0
  3. package/dist/ast.d.ts +193 -0
  4. package/dist/ast.js +9 -0
  5. package/dist/builtins.d.ts +29 -0
  6. package/dist/builtins.js +66 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.js +297 -0
  9. package/dist/diagnostics.d.ts +47 -0
  10. package/dist/diagnostics.js +8 -0
  11. package/dist/errors.d.ts +131 -0
  12. package/dist/errors.js +159 -0
  13. package/dist/executor.d.ts +66 -0
  14. package/dist/executor.js +320 -0
  15. package/dist/expectation.d.ts +61 -0
  16. package/dist/expectation.js +222 -0
  17. package/dist/index.d.ts +51 -0
  18. package/dist/index.js +36 -0
  19. package/dist/lexer.d.ts +22 -0
  20. package/dist/lexer.js +284 -0
  21. package/dist/loader.d.ts +21 -0
  22. package/dist/loader.js +81 -0
  23. package/dist/parser.d.ts +24 -0
  24. package/dist/parser.js +502 -0
  25. package/dist/permissions.d.ts +139 -0
  26. package/dist/permissions.js +325 -0
  27. package/dist/plugin.d.ts +90 -0
  28. package/dist/plugin.js +9 -0
  29. package/dist/plugins/browser.d.ts +24 -0
  30. package/dist/plugins/browser.js +896 -0
  31. package/dist/plugins/cli.d.ts +17 -0
  32. package/dist/plugins/cli.js +134 -0
  33. package/dist/plugins/db-e2e-probe.d.ts +14 -0
  34. package/dist/plugins/db-e2e-probe.js +112 -0
  35. package/dist/plugins/db.d.ts +19 -0
  36. package/dist/plugins/db.js +199 -0
  37. package/dist/plugins/demo.d.ts +17 -0
  38. package/dist/plugins/demo.js +70 -0
  39. package/dist/plugins/env.d.ts +18 -0
  40. package/dist/plugins/env.js +87 -0
  41. package/dist/plugins/fs.d.ts +16 -0
  42. package/dist/plugins/fs.js +415 -0
  43. package/dist/plugins/http.d.ts +19 -0
  44. package/dist/plugins/http.js +505 -0
  45. package/dist/plugins/jwt.d.ts +17 -0
  46. package/dist/plugins/jwt.js +342 -0
  47. package/dist/plugins/sample.d.ts +27 -0
  48. package/dist/plugins/sample.js +400 -0
  49. package/dist/plugins/url.d.ts +18 -0
  50. package/dist/plugins/url.js +126 -0
  51. package/dist/project-config.d.ts +163 -0
  52. package/dist/project-config.js +497 -0
  53. package/dist/registry.d.ts +56 -0
  54. package/dist/registry.js +110 -0
  55. package/dist/reporter.d.ts +30 -0
  56. package/dist/reporter.js +237 -0
  57. package/dist/run.d.ts +74 -0
  58. package/dist/run.js +179 -0
  59. package/dist/runner.d.ts +52 -0
  60. package/dist/runner.js +38 -0
  61. package/dist/source.d.ts +37 -0
  62. package/dist/source.js +31 -0
  63. package/dist/sources.d.ts +45 -0
  64. package/dist/sources.js +54 -0
  65. package/dist/tokens.d.ts +34 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/transport-stdio.d.ts +34 -0
  68. package/dist/transport-stdio.js +400 -0
  69. package/dist/values.d.ts +48 -0
  70. package/dist/values.js +52 -0
  71. package/dist/workers.d.ts +40 -0
  72. package/dist/workers.js +26 -0
  73. package/dist/workspace-none.d.ts +23 -0
  74. package/dist/workspace-none.js +33 -0
  75. package/dist/workspace.d.ts +47 -0
  76. package/dist/workspace.js +116 -0
  77. package/package.json +28 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The deny-by-default permission engine. `spec run` grants nothing; every
3
+ * privileged capability (process execution, network, environment variables,
4
+ * host filesystem) must be granted by the caller with an `--allow-*` flag,
5
+ * and every check flows through this module — plugins never self-authorize.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Result } from "@sdxc/result";
11
+ import { PermissionDeniedError, SpecError } from "./errors.js";
12
+ /** The permission families v1 knows about. */
13
+ export type PermissionKind = "run" | "net" | "env" | "host-fs";
14
+ /**
15
+ * One permission family's grant: denied entirely, granted for everything, or
16
+ * granted for an explicit scope list (executable names, `host[:port]`s,
17
+ * variable names, directory prefixes).
18
+ */
19
+ export type Grant = {
20
+ mode: "denied";
21
+ } | {
22
+ mode: "all";
23
+ } | {
24
+ mode: "scoped";
25
+ scopes: string[];
26
+ };
27
+ /** The caller's complete grant set, as parsed from `--allow-*` flags. */
28
+ export interface Grants {
29
+ /** Process execution: scopes are executable basenames. */
30
+ run: Grant;
31
+ /** Network access: scopes are `host` or `host:port`. */
32
+ net: Grant;
33
+ /** Environment variables: scopes are exact variable names. */
34
+ env: Grant;
35
+ /** Host filesystem outside the workspace: scopes are directory prefixes. */
36
+ hostFs: Grant;
37
+ }
38
+ /**
39
+ * One already-validated entry of a `spec/config.jsonc` `permissions.allow`
40
+ * list: the family it grants and its scopes, empty `scopes` meaning the
41
+ * whole family and `"plugins"` mapping to the plugin launch grant.
42
+ */
43
+ export interface ConfigPermissionEntry {
44
+ /** The family this entry grants: a {@link PermissionKind} or `"plugins"`. */
45
+ family: PermissionKind | "plugins";
46
+ /** The scopes; empty means the whole family. */
47
+ scopes: string[];
48
+ }
49
+ /**
50
+ * The runtime's single enforcement authority: built once per `spec run`,
51
+ * handed to tools through context so checks run in runtime-owned code, and
52
+ * every failure names the permission, resource, and flag that grants it.
53
+ */
54
+ export interface PermissionSet {
55
+ /**
56
+ * May the spec execute this program? Matched against the executable's
57
+ * basename.
58
+ */
59
+ checkRun(executable: string): Result<undefined, PermissionDeniedError>;
60
+ /** May the spec reach this network host (and optional port)? */
61
+ checkNet(host: string, port?: number): Result<undefined, PermissionDeniedError>;
62
+ /** May the spec read this environment variable? */
63
+ checkEnv(name: string): Result<undefined, PermissionDeniedError>;
64
+ /**
65
+ * May the spec touch this absolute host path, outside any workspace?
66
+ * Granted when the path is inside a granted directory prefix.
67
+ */
68
+ checkHostFs(path: string): Result<undefined, PermissionDeniedError>;
69
+ /**
70
+ * The environment variable names the caller granted, for building the
71
+ * filtered environment of child processes — subprocesses inherit granted
72
+ * variables only, never the full host environment.
73
+ */
74
+ grantedEnvNames(): string[];
75
+ }
76
+ /**
77
+ * Parse the `--allow-*` flags out of a `spec run` argument list. A bare flag
78
+ * grants its whole family, `--allow-x=a,b` scopes it, and an absent flag
79
+ * leaves the family denied; other arguments pass through in `remaining`.
80
+ *
81
+ * @param args - The raw CLI arguments to scan.
82
+ * @returns The parsed grants plus the untouched arguments, or a usage error
83
+ * for an unknown `--allow-*` flag or an empty scope list.
84
+ */
85
+ export declare function parseGrants(args: string[]): Result<{
86
+ grants: Grants;
87
+ remaining: string[];
88
+ }, SpecError>;
89
+ /**
90
+ * Build the runtime's single {@link PermissionSet} from a parsed grant set.
91
+ * Every check denies by default and every denial carries the exact
92
+ * `spec run --allow-*` flag that would grant the attempted resource.
93
+ *
94
+ * @param grants - The caller's grants, from {@link parseGrants}.
95
+ * @returns The permission set every capability check flows through.
96
+ */
97
+ export declare function createPermissionSet(grants: Grants): PermissionSet;
98
+ /**
99
+ * Fold a validated `permissions.allow` list into a {@link Grants} set,
100
+ * widening each family the way `--allow-*` flags do so config and CLI grants
101
+ * merge identically; `"plugins"` entries map to the plugin launch grant.
102
+ *
103
+ * @param entries - The validated allow-list entries.
104
+ * @returns The grants the config declares, families it never names left denied.
105
+ */
106
+ export declare function grantsFromConfig(entries: readonly ConfigPermissionEntry[]): Grants;
107
+ /**
108
+ * Whether opting into the config's declared grants would lift a denial: the
109
+ * test behind the `--allow-config` DX hint. A family-gate denial checks only
110
+ * whether the config declares the family; any other denial checks its scope.
111
+ *
112
+ * @param config - The grants the config declares.
113
+ * @param permission - The denied family.
114
+ * @param resource - The denial's resource string, as the denial reported it.
115
+ * @param familyGate - Whether the coarse family gate raised the denial.
116
+ * @returns Whether `--allow-config` would have granted past this denial.
117
+ */
118
+ export declare function configWouldAdmit(config: Grants, permission: PermissionKind, resource: string, familyGate: boolean): boolean;
119
+ /**
120
+ * Union two grant sets family by family: the wider mode wins and two scoped
121
+ * grants merge their scope lists. Combines the caller's CLI grants with the
122
+ * config's declared grants when `--allow-config` opts in, always widening.
123
+ *
124
+ * @param base - The caller's CLI grants.
125
+ * @param extra - The config's declared grants to fold in.
126
+ * @returns The unioned grant set.
127
+ */
128
+ export declare function mergeGrants(base: Grants, extra: Grants): Grants;
129
+ /**
130
+ * Whether a grant set would admit a denied resource, deciding the
131
+ * `--allow-config` DX hint by dispatching on the family and reusing the
132
+ * exact checks enforcement runs, so the hint matches what the flag would do.
133
+ *
134
+ * @param grants - The grant set to test against (the config's declared grants).
135
+ * @param permission - The denied family.
136
+ * @param resource - The denial's resource string, as the denial reported it.
137
+ * @returns Whether the grants would have admitted that resource.
138
+ */
139
+ export declare function grantsAdmit(grants: Grants, permission: PermissionKind, resource: string): boolean;
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The deny-by-default permission engine. `spec run` grants nothing; every
3
+ * privileged capability (process execution, network, environment variables,
4
+ * host filesystem) must be granted by the caller with an `--allow-*` flag,
5
+ * and every check flows through this module — plugins never self-authorize.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { lstatSync, realpathSync } from "node:fs";
11
+ import { basename, dirname, resolve, sep } from "node:path";
12
+ import { failure, isSuccess, success } from "@sdxc/result";
13
+ import { PermissionDeniedError, SpecError } from "./errors.js";
14
+ /** Maps each recognized `--allow-*` flag to the grant family it feeds. */
15
+ const ALLOW_FLAGS = new Map([
16
+ ["--allow-run", "run"],
17
+ ["--allow-net", "net"],
18
+ ["--allow-env", "env"],
19
+ ["--allow-host-fs", "hostFs"],
20
+ ]);
21
+ /**
22
+ * Parse the `--allow-*` flags out of a `spec run` argument list. A bare flag
23
+ * grants its whole family, `--allow-x=a,b` scopes it, and an absent flag
24
+ * leaves the family denied; other arguments pass through in `remaining`.
25
+ *
26
+ * @param args - The raw CLI arguments to scan.
27
+ * @returns The parsed grants plus the untouched arguments, or a usage error
28
+ * for an unknown `--allow-*` flag or an empty scope list.
29
+ */
30
+ export function parseGrants(args) {
31
+ let grants = {
32
+ run: { mode: "denied" },
33
+ net: { mode: "denied" },
34
+ env: { mode: "denied" },
35
+ hostFs: { mode: "denied" },
36
+ };
37
+ let remaining = [];
38
+ for (let argument of args) {
39
+ if (!argument.startsWith("--allow-")) {
40
+ remaining.push(argument);
41
+ continue;
42
+ }
43
+ let separator = argument.indexOf("=");
44
+ let flag = separator === -1 ? argument : argument.slice(0, separator);
45
+ let family = ALLOW_FLAGS.get(flag);
46
+ if (family === undefined) {
47
+ return failure(new SpecError("usage-error", `Unknown permission flag: ${flag}. Known flags: ${[...ALLOW_FLAGS.keys()].join(", ")}.`));
48
+ }
49
+ if (separator === -1) {
50
+ grants[family] = { mode: "all" };
51
+ continue;
52
+ }
53
+ let scopes = argument
54
+ .slice(separator + 1)
55
+ .split(",")
56
+ .map((scope) => scope.trim())
57
+ .filter((scope) => scope.length > 0);
58
+ if (scopes.length === 0) {
59
+ return failure(new SpecError("usage-error", `${flag}= expects a comma-separated scope list, e.g. ${flag}=<scope>.`));
60
+ }
61
+ grants[family] = widenGrant(grants[family], scopes);
62
+ }
63
+ return success({ grants, remaining });
64
+ }
65
+ /**
66
+ * Build the runtime's single {@link PermissionSet} from a parsed grant set.
67
+ * Every check denies by default and every denial carries the exact
68
+ * `spec run --allow-*` flag that would grant the attempted resource.
69
+ *
70
+ * @param grants - The caller's grants, from {@link parseGrants}.
71
+ * @returns The permission set every capability check flows through.
72
+ */
73
+ export function createPermissionSet(grants) {
74
+ return {
75
+ checkRun(executable) {
76
+ let name = basename(executable);
77
+ if (grants.run.mode === "all")
78
+ return success(undefined);
79
+ if (grants.run.mode === "scoped" && grants.run.scopes.includes(name)) {
80
+ return success(undefined);
81
+ }
82
+ return failure(new PermissionDeniedError("run", executable, `spec run --allow-run=${name}`));
83
+ },
84
+ checkNet(host, port) {
85
+ if (grants.net.mode === "all")
86
+ return success(undefined);
87
+ if (grants.net.mode === "scoped") {
88
+ for (let scope of grants.net.scopes) {
89
+ if (netScopeAdmits(scope, host, port))
90
+ return success(undefined);
91
+ }
92
+ }
93
+ let resource = port === undefined ? host : `${host}:${port}`;
94
+ return failure(new PermissionDeniedError("net", resource, `spec run --allow-net=${resource}`));
95
+ },
96
+ checkEnv(name) {
97
+ if (grants.env.mode === "all")
98
+ return success(undefined);
99
+ if (grants.env.mode === "scoped" && grants.env.scopes.includes(name)) {
100
+ return success(undefined);
101
+ }
102
+ return failure(new PermissionDeniedError("env", name, `spec run --allow-env=${name}`));
103
+ },
104
+ checkHostFs(path) {
105
+ let resolved = resolve(path);
106
+ if (grants.hostFs.mode === "all")
107
+ return success(undefined);
108
+ if (grants.hostFs.mode === "scoped") {
109
+ let followed = followExistingAncestors(resolved);
110
+ if (followed !== undefined) {
111
+ for (let scope of grants.hostFs.scopes) {
112
+ let granted = followExistingAncestors(resolve(scope));
113
+ if (granted !== undefined && directoryContains(granted, followed)) {
114
+ return success(undefined);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return failure(new PermissionDeniedError("host-fs", path, `spec run --allow-host-fs=${dirname(resolved)}`));
120
+ },
121
+ grantedEnvNames() {
122
+ if (grants.env.mode === "all")
123
+ return Object.keys(process.env);
124
+ if (grants.env.mode === "scoped")
125
+ return [...grants.env.scopes];
126
+ return [];
127
+ },
128
+ };
129
+ }
130
+ /** Maps a permission family to its key in the {@link Grants} record. */
131
+ const GRANT_KEYS = {
132
+ run: "run",
133
+ net: "net",
134
+ env: "env",
135
+ "host-fs": "hostFs",
136
+ };
137
+ /**
138
+ * Fold a validated `permissions.allow` list into a {@link Grants} set,
139
+ * widening each family the way `--allow-*` flags do so config and CLI grants
140
+ * merge identically; `"plugins"` entries map to the plugin launch grant.
141
+ *
142
+ * @param entries - The validated allow-list entries.
143
+ * @returns The grants the config declares, families it never names left denied.
144
+ */
145
+ export function grantsFromConfig(entries) {
146
+ let grants = {
147
+ run: { mode: "denied" },
148
+ net: { mode: "denied" },
149
+ env: { mode: "denied" },
150
+ hostFs: { mode: "denied" },
151
+ };
152
+ for (let entry of entries) {
153
+ if (entry.family === "plugins")
154
+ continue;
155
+ let key = GRANT_KEYS[entry.family];
156
+ grants[key] =
157
+ entry.scopes.length === 0 ? { mode: "all" } : widenGrant(grants[key], entry.scopes);
158
+ }
159
+ return grants;
160
+ }
161
+ /**
162
+ * Whether opting into the config's declared grants would lift a denial: the
163
+ * test behind the `--allow-config` DX hint. A family-gate denial checks only
164
+ * whether the config declares the family; any other denial checks its scope.
165
+ *
166
+ * @param config - The grants the config declares.
167
+ * @param permission - The denied family.
168
+ * @param resource - The denial's resource string, as the denial reported it.
169
+ * @param familyGate - Whether the coarse family gate raised the denial.
170
+ * @returns Whether `--allow-config` would have granted past this denial.
171
+ */
172
+ export function configWouldAdmit(config, permission, resource, familyGate) {
173
+ if (familyGate)
174
+ return config[GRANT_KEYS[permission]].mode !== "denied";
175
+ return grantsAdmit(config, permission, resource);
176
+ }
177
+ /**
178
+ * Union two grant sets family by family: the wider mode wins and two scoped
179
+ * grants merge their scope lists. Combines the caller's CLI grants with the
180
+ * config's declared grants when `--allow-config` opts in, always widening.
181
+ *
182
+ * @param base - The caller's CLI grants.
183
+ * @param extra - The config's declared grants to fold in.
184
+ * @returns The unioned grant set.
185
+ */
186
+ export function mergeGrants(base, extra) {
187
+ return {
188
+ run: mergeGrant(base.run, extra.run),
189
+ net: mergeGrant(base.net, extra.net),
190
+ env: mergeGrant(base.env, extra.env),
191
+ hostFs: mergeGrant(base.hostFs, extra.hostFs),
192
+ };
193
+ }
194
+ /** Union one family's two grants, widening `base` by whatever `extra` adds. */
195
+ function mergeGrant(base, extra) {
196
+ if (extra.mode === "denied")
197
+ return base;
198
+ if (extra.mode === "all")
199
+ return { mode: "all" };
200
+ return widenGrant(base, extra.scopes);
201
+ }
202
+ /**
203
+ * Whether a grant set would admit a denied resource, deciding the
204
+ * `--allow-config` DX hint by dispatching on the family and reusing the
205
+ * exact checks enforcement runs, so the hint matches what the flag would do.
206
+ *
207
+ * @param grants - The grant set to test against (the config's declared grants).
208
+ * @param permission - The denied family.
209
+ * @param resource - The denial's resource string, as the denial reported it.
210
+ * @returns Whether the grants would have admitted that resource.
211
+ */
212
+ export function grantsAdmit(grants, permission, resource) {
213
+ let set = createPermissionSet(grants);
214
+ if (permission === "run")
215
+ return isSuccess(set.checkRun(resource));
216
+ if (permission === "env")
217
+ return isSuccess(set.checkEnv(resource));
218
+ if (permission === "host-fs")
219
+ return isSuccess(set.checkHostFs(resource));
220
+ let parsed = splitNetResource(resource);
221
+ return isSuccess(set.checkNet(parsed.host, parsed.port));
222
+ }
223
+ /** Split a `host[:port]` denial resource back into host and optional port. */
224
+ function splitNetResource(resource) {
225
+ let separator = resource.lastIndexOf(":");
226
+ if (separator === -1)
227
+ return { host: resource, port: undefined };
228
+ let suffix = resource.slice(separator + 1);
229
+ if (!/^\d+$/.test(suffix))
230
+ return { host: resource, port: undefined };
231
+ return { host: resource.slice(0, separator), port: Number(suffix) };
232
+ }
233
+ /**
234
+ * Merge one flag occurrence into a family's accumulated grant: an existing
235
+ * grant-all absorbs later scopes, repeated scoped flags union their scope
236
+ * lists, and a denied family upgrades to the new scopes.
237
+ *
238
+ * @param current - The grant accumulated so far.
239
+ * @param scopes - The scope list of the flag being merged.
240
+ * @returns The widened grant.
241
+ */
242
+ function widenGrant(current, scopes) {
243
+ if (current.mode === "all")
244
+ return current;
245
+ if (current.mode === "denied")
246
+ return { mode: "scoped", scopes: [...scopes] };
247
+ let merged = [...current.scopes];
248
+ for (let scope of scopes) {
249
+ if (!merged.includes(scope))
250
+ merged.push(scope);
251
+ }
252
+ return { mode: "scoped", scopes: merged };
253
+ }
254
+ /**
255
+ * Does one `host[:port]` scope admit this host/port pair? A scope without a
256
+ * port admits every port of its host; a scope that pins a port requires the
257
+ * check to name that exact port.
258
+ *
259
+ * @param scope - A granted `host` or `host:port` scope.
260
+ * @param host - The host being reached.
261
+ * @param port - The port being reached, when known.
262
+ * @returns Whether the scope covers the attempt.
263
+ */
264
+ function netScopeAdmits(scope, host, port) {
265
+ let separator = scope.lastIndexOf(":");
266
+ if (separator === -1)
267
+ return scope === host;
268
+ let scopePort = scope.slice(separator + 1);
269
+ if (!/^\d+$/.test(scopePort))
270
+ return scope === host;
271
+ return scope.slice(0, separator) === host && port !== undefined && Number(scopePort) === port;
272
+ }
273
+ /**
274
+ * Re-resolve the symlinks among a path's existing ancestors: the deepest
275
+ * component that exists on disk is realpathed and the remainder is appended
276
+ * back untouched, so a symlink inside a granted directory stays contained.
277
+ *
278
+ * @param path - An absolute, syntactically resolved path.
279
+ * @returns The symlink-free spelling, or undefined when the existing ancestor
280
+ * cannot be resolved (e.g. a dangling symlink) — refuse what you cannot verify.
281
+ */
282
+ function followExistingAncestors(path) {
283
+ let ancestor = path;
284
+ while (!entryExists(ancestor)) {
285
+ let parent = dirname(ancestor);
286
+ if (parent === ancestor)
287
+ break;
288
+ ancestor = parent;
289
+ }
290
+ try {
291
+ return realpathSync(ancestor) + path.slice(ancestor.length);
292
+ }
293
+ catch {
294
+ return undefined;
295
+ }
296
+ }
297
+ /**
298
+ * Does a filesystem entry exist at this path, without following symlinks?
299
+ *
300
+ * @param path - The absolute path to probe.
301
+ * @returns Whether lstat finds an entry there.
302
+ */
303
+ function entryExists(path) {
304
+ try {
305
+ lstatSync(path);
306
+ return true;
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ }
312
+ /**
313
+ * Path-segment-aware prefix test: `/a/b` contains itself and `/a/b/c`, and
314
+ * never `/a/bc`. Both sides must already be resolved absolute paths.
315
+ *
316
+ * @param directory - The granted directory.
317
+ * @param path - The absolute path being checked.
318
+ * @returns Whether the path lives inside the directory.
319
+ */
320
+ function directoryContains(directory, path) {
321
+ if (path === directory)
322
+ return true;
323
+ let prefix = directory.endsWith(sep) ? directory : directory + sep;
324
+ return path.startsWith(prefix);
325
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The plugin protocol: the single extension seam of the runtime. Built-in
3
+ * capabilities, external stdio plugins, and test fakes all implement the same
4
+ * typed-tool interface, so the executor, permission engine, and diagnostics
5
+ * never know which kind they are talking to.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Result } from "@sdxc/result";
11
+ import type { Random } from "@sdxc/sample";
12
+ import type { SpecError } from "./errors.js";
13
+ import type { PermissionKind, PermissionSet } from "./permissions.js";
14
+ import type { ToolArg, Value } from "./values.js";
15
+ import type { Workspace } from "./workspace.js";
16
+ /** One declared parameter of a tool, for diagnostics and documentation. */
17
+ export interface ToolParam {
18
+ /** The parameter's name as documentation shows it. */
19
+ name: string;
20
+ /** Whether the parameter is a value or a bare-word symbol. */
21
+ kind: "value" | "word";
22
+ /** Whether a call must supply it. */
23
+ required: boolean;
24
+ /** One-line description of what the parameter means. */
25
+ summary: string;
26
+ }
27
+ /**
28
+ * A tool a plugin exposes. `kind` separates mutations from observations: only
29
+ * observables may run inside `eventually` or head the observable form of
30
+ * `expect`. `requires` declares the permission family the runtime enforces.
31
+ */
32
+ export interface ToolDescriptor {
33
+ /** The tool's name inside its namespace, e.g. `"write"`. */
34
+ name: string;
35
+ /** One-line description shown in diagnostics and docs. */
36
+ summary: string;
37
+ /** Whether the tool mutates (`action`) or only observes (`observable`). */
38
+ kind: "action" | "observable";
39
+ /** Permission family the tool needs, absent for workspace-safe tools. */
40
+ requires?: PermissionKind;
41
+ /** Declared parameters, in positional order. */
42
+ params: ToolParam[];
43
+ }
44
+ /**
45
+ * What the runtime hands a tool for one call: the test's workspace and the
46
+ * caller's grants. The `PermissionSet` is runtime-owned — a plugin calling
47
+ * `check*` asks the runtime, since every call is already gated on `requires`.
48
+ */
49
+ export interface ToolContext {
50
+ /** The current test's isolated workspace. */
51
+ workspace: Workspace;
52
+ /** The caller's grant set, for scoped checks (which host? which binary?). */
53
+ permissions: PermissionSet;
54
+ /**
55
+ * The test's own seeded stream, created from the run's seed and the test's
56
+ * identity. A plugin draws every random value from here, which is what makes
57
+ * generated data reproduce regardless of how tests interleave.
58
+ */
59
+ random: Random;
60
+ /**
61
+ * The instant the test started, frozen for its whole run so a tool that
62
+ * reports or generates a time answers consistently within one test.
63
+ */
64
+ now: Date;
65
+ }
66
+ /**
67
+ * A connected plugin: one namespace exposing typed tools. Implementations
68
+ * must not throw; every failure is a `Result` error.
69
+ */
70
+ export interface Plugin {
71
+ /** The namespace the plugin's tools live under, e.g. `"fs"`. */
72
+ namespace: string;
73
+ /** The tools this plugin exposes. Stable for the plugin's lifetime. */
74
+ describe(): ToolDescriptor[];
75
+ /**
76
+ * Execute one tool call.
77
+ *
78
+ * @param tool - The tool name within this namespace.
79
+ * @param args - Evaluated arguments, values and words, in call order.
80
+ * @param context - The test's workspace and the caller's grants.
81
+ * @returns The tool's result value, or a structured failure.
82
+ */
83
+ call(tool: string, args: ToolArg[], context: ToolContext): Promise<Result<Value, SpecError>>;
84
+ /**
85
+ * Release any process-external resources the plugin accumulated — browser
86
+ * sessions, connections, spawned daemons. Optional because built-ins hold
87
+ * nothing to release; must be best-effort so teardown never fails a run.
88
+ */
89
+ dispose?(): Promise<void>;
90
+ }
package/dist/plugin.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The plugin protocol: the single extension seam of the runtime. Built-in
3
+ * capabilities, external stdio plugins, and test fakes all implement the same
4
+ * typed-tool interface, so the executor, permission engine, and diagnostics
5
+ * never know which kind they are talking to.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The built-in `browser` capability: drive a real browser through the
3
+ * accessibility tree, not DOM internals, via the globally-installed
4
+ * `agent-browser` CLI. Reaching web content is privileged, so the whole
5
+ * family requires `net`; the trusted binary needs no `run` grant (ADR-007 §4).
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Plugin } from "../plugin.js";
11
+ /**
12
+ * Create the built-in `browser` plugin: accessibility-first web-interaction
13
+ * tools backed by `agent-browser`. Each call keys a session to the test's
14
+ * workspace, isolating browser state; {@link Plugin.dispose} closes them all.
15
+ */
16
+ export declare function createBrowserPlugin(): Plugin;
17
+ /**
18
+ * Locate the trusted `agent-browser` CLI by scanning PATH for an executable
19
+ * of that name, the way a shell would. The resolved path stays useful for
20
+ * diagnostics; `null` is the single signal every caller treats as "not installed".
21
+ *
22
+ * @returns The absolute path of the binary, or null when it is not installed.
23
+ */
24
+ export declare function browserBinaryPath(): string | null;