@crewhaus/plugin-sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,39 +1,37 @@
1
1
  {
2
2
  "name": "@crewhaus/plugin-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
- "description": "Studio plugin SDK — typed surface for third-party panes / hooks / renderers (Section 26 Studio)",
5
+ "description": "Plugin SDK v2 public typed surface for third-party tools / channels / models / graders / target backends, with manifest validation and Ed25519 signature verification (Section 41)",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts"
10
10
  },
11
11
  "scripts": {
12
- "start": "bun src/scripts/start.ts",
13
12
  "test": "bun test src"
14
13
  },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/tool-catalog": "0.1.2"
17
+ },
15
18
  "license": "Apache-2.0",
16
19
  "author": {
17
20
  "name": "Max Meier",
18
- "email": "max@studiomax.io",
19
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
20
23
  },
21
24
  "repository": {
22
25
  "type": "git",
23
- "url": "git+https://github.com/crewhaus/utilities.git",
24
- "directory": "plugin-sdk"
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/plugin-sdk"
25
28
  },
26
- "homepage": "https://github.com/crewhaus/utilities/tree/main/plugin-sdk#readme",
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/plugin-sdk#readme",
27
30
  "bugs": {
28
- "url": "https://github.com/crewhaus/utilities/issues"
31
+ "url": "https://github.com/crewhaus/factory/issues"
29
32
  },
30
33
  "publishConfig": {
31
- "access": "restricted"
34
+ "access": "public"
32
35
  },
33
- "files": [
34
- "src",
35
- "README.md",
36
- "LICENSE",
37
- "NOTICE"
38
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
37
  }
package/src/index.test.ts CHANGED
@@ -1,154 +1,237 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  PluginSdkError,
4
- assertPluginPathsStaySandboxed,
4
+ canonicalJson,
5
5
  definePlugin,
6
- isFsAllowed,
7
- isNetAllowed,
8
- } from "./index.js";
9
-
10
- describe("definePlugin (T1)", () => {
11
- test("returns a frozen plugin definition with the supplied fields", () => {
12
- const p = definePlugin({
13
- name: "fixture",
14
- version: "0.0.1",
15
- panes: [{ id: "main", title: "Hello", html: "<div>Hello from plugin</div>" }],
16
- });
17
- expect(p.name).toBe("fixture");
18
- expect(p.version).toBe("0.0.1");
19
- expect(p.panes?.[0]?.title).toBe("Hello");
20
- expect(Object.isFrozen(p)).toBe(true);
21
- });
6
+ manifestPayloadForSigning,
7
+ validatePluginManifest,
8
+ } from "./index";
22
9
 
23
- test("rejects empty name / version", () => {
24
- expect(() => definePlugin({ name: "", version: "1" })).toThrow(PluginSdkError);
25
- expect(() => definePlugin({ name: "x", version: "" })).toThrow(PluginSdkError);
10
+ describe("validatePluginManifest", () => {
11
+ test("accepts a minimal valid manifest", () => {
12
+ const m = validatePluginManifest({ name: "my-plugin", version: "1.2.3" });
13
+ expect(m.name).toBe("my-plugin");
14
+ expect(m.version).toBe("1.2.3");
26
15
  });
27
16
 
28
- test("rejects duplicate pane ids within a plugin", () => {
17
+ test("accepts pre-release + build metadata in semver", () => {
29
18
  expect(() =>
30
- definePlugin({
31
- name: "p",
32
- version: "1",
33
- panes: [
34
- { id: "a", title: "A", html: "" },
35
- { id: "a", title: "A2", html: "" },
36
- ],
37
- }),
38
- ).toThrow(/duplicate pane id "a"/);
39
- });
40
-
41
- test("hooks pass through verbatim and are callable", () => {
42
- let calls = 0;
43
- const p = definePlugin({
44
- name: "p",
45
- version: "1",
46
- hooks: {
47
- onTraceEvent: () => {
48
- calls += 1;
49
- },
50
- },
51
- });
52
- p.hooks?.onTraceEvent?.({ kind: "x" });
53
- expect(calls).toBe(1);
19
+ validatePluginManifest({ name: "my-plugin", version: "1.2.3-beta.1+build.42" }),
20
+ ).not.toThrow();
54
21
  });
55
- });
56
22
 
57
- describe("assertPluginPathsStaySandboxed (T8 — sandbox isolation)", () => {
58
- test("allows file:// URLs inside the sandbox root", () => {
59
- const p = definePlugin({
60
- name: "p",
61
- version: "1",
62
- panes: [
63
- {
64
- id: "x",
65
- title: "x",
66
- html: '<a href="file:///home/u/.crewhaus/plugins/p/asset.png">a</a>',
67
- },
68
- ],
69
- });
70
- expect(() => assertPluginPathsStaySandboxed(p, "/home/u/.crewhaus/plugins/p/")).not.toThrow();
23
+ test("rejects missing name", () => {
24
+ expect(() => validatePluginManifest({ version: "1.0.0" })).toThrow(PluginSdkError);
71
25
  });
72
26
 
73
- test("rejects file:// URLs outside the sandbox root", () => {
74
- const p = definePlugin({
75
- name: "p",
76
- version: "1",
77
- panes: [{ id: "x", title: "x", html: '<img src="file:///etc/passwd" />' }],
78
- });
79
- expect(() => assertPluginPathsStaySandboxed(p, "/home/u/.crewhaus/plugins/p/")).toThrow(
80
- /outside its sandbox root/,
27
+ test("rejects invalid name", () => {
28
+ expect(() => validatePluginManifest({ name: "MyPlugin", version: "1.0.0" })).toThrow(
29
+ PluginSdkError,
30
+ );
31
+ expect(() => validatePluginManifest({ name: "-leading", version: "1.0.0" })).toThrow(
32
+ PluginSdkError,
33
+ );
34
+ expect(() => validatePluginManifest({ name: "trailing-", version: "1.0.0" })).toThrow(
35
+ PluginSdkError,
36
+ );
37
+ expect(() => validatePluginManifest({ name: "1starts-with-digit", version: "1.0.0" })).toThrow(
38
+ PluginSdkError,
39
+ );
40
+ });
41
+
42
+ test("rejects non-semver version", () => {
43
+ expect(() => validatePluginManifest({ name: "ok-name", version: "v1.2.3" })).toThrow(
44
+ PluginSdkError,
45
+ );
46
+ expect(() => validatePluginManifest({ name: "ok-name", version: "1.2" })).toThrow(
47
+ PluginSdkError,
81
48
  );
82
49
  });
83
50
 
84
- test("plugin with no panes is a no-op", () => {
85
- const p = definePlugin({ name: "p", version: "1" });
86
- expect(() => assertPluginPathsStaySandboxed(p, "/home/u/.crewhaus/plugins/p/")).not.toThrow();
51
+ test("validates engines.crewhaus when present", () => {
52
+ expect(() =>
53
+ validatePluginManifest({
54
+ name: "ok-name",
55
+ version: "1.0.0",
56
+ engines: { crewhaus: "^0.2.0" },
57
+ }),
58
+ ).not.toThrow();
59
+ expect(() =>
60
+ validatePluginManifest({
61
+ name: "ok-name",
62
+ version: "1.0.0",
63
+ engines: { crewhaus: 42 },
64
+ }),
65
+ ).toThrow(PluginSdkError);
87
66
  });
88
- });
89
67
 
90
- describe("plugin-sdk v1 Section 31 content sandbox (T8)", () => {
91
- test("definePlugin validates the permissions schema", () => {
68
+ test("validates permissions arrays are string arrays", () => {
69
+ expect(() =>
70
+ validatePluginManifest({
71
+ name: "ok-name",
72
+ version: "1.0.0",
73
+ permissions: { fs: ["read:./data/**"], net: ["fetch:https://api.example.com/**"] },
74
+ }),
75
+ ).not.toThrow();
92
76
  expect(() =>
93
- definePlugin({
94
- name: "p",
95
- version: "1",
96
- permissions: { fs: ["this-is-not-prefixed"] },
77
+ validatePluginManifest({
78
+ name: "ok-name",
79
+ version: "1.0.0",
80
+ permissions: { fs: [42] },
97
81
  }),
98
82
  ).toThrow(PluginSdkError);
99
83
  expect(() =>
100
- definePlugin({
101
- name: "p",
102
- version: "1",
103
- permissions: { net: ["this-is-not-prefixed"] },
84
+ validatePluginManifest({
85
+ name: "ok-name",
86
+ version: "1.0.0",
87
+ permissions: { tools: "Bash" }, // must be array
104
88
  }),
105
89
  ).toThrow(PluginSdkError);
106
90
  });
107
91
 
108
- test("isFsAllowed: empty permissions = fail-closed", () => {
109
- expect(isFsAllowed(undefined, "/etc/passwd")).toBe(false);
110
- expect(isFsAllowed({}, "/etc/passwd")).toBe(false);
92
+ test("validates signature shape", () => {
93
+ const ok = {
94
+ name: "ok-name",
95
+ version: "1.0.0",
96
+ signature: {
97
+ algorithm: "ed25519",
98
+ publicKeyB64: "base64-data",
99
+ sigB64: "base64-sig",
100
+ },
101
+ };
102
+ expect(() => validatePluginManifest(ok)).not.toThrow();
111
103
  });
112
104
 
113
- test("isFsAllowed: sandbox-relative read pattern allows matching paths", () => {
114
- const perms = { fs: ["read:/sandbox/data/**"] };
115
- expect(isFsAllowed(perms, "/sandbox/data/file.json")).toBe(true);
116
- expect(isFsAllowed(perms, "/sandbox/data/nested/file.json")).toBe(true);
117
- expect(isFsAllowed(perms, "/etc/passwd")).toBe(false);
105
+ test("rejects non-ed25519 algorithms", () => {
106
+ expect(() =>
107
+ validatePluginManifest({
108
+ name: "ok-name",
109
+ version: "1.0.0",
110
+ signature: { algorithm: "rsa", publicKeyB64: "x", sigB64: "y" },
111
+ }),
112
+ ).toThrow(PluginSdkError);
118
113
  });
119
114
 
120
- test("isFsAllowed: blocks /etc/passwd outside sandbox", () => {
121
- const perms = { fs: ["read:/sandbox/**"] };
122
- expect(isFsAllowed(perms, "/etc/passwd")).toBe(false);
115
+ test("rejects signature missing publicKeyB64 / sigB64", () => {
116
+ expect(() =>
117
+ validatePluginManifest({
118
+ name: "ok-name",
119
+ version: "1.0.0",
120
+ signature: { algorithm: "ed25519", publicKeyB64: "x" },
121
+ }),
122
+ ).toThrow(PluginSdkError);
123
123
  });
124
124
 
125
- test("isNetAllowed: empty permissions = fail-closed", () => {
126
- expect(isNetAllowed(undefined, "https://example.com/x")).toBe(false);
125
+ test("rejects non-object manifest", () => {
126
+ expect(() => validatePluginManifest(null)).toThrow(PluginSdkError);
127
+ expect(() => validatePluginManifest("not an object")).toThrow(PluginSdkError);
128
+ expect(() => validatePluginManifest(42)).toThrow(PluginSdkError);
127
129
  });
130
+ });
128
131
 
129
- test("isNetAllowed: fetch glob honored", () => {
130
- const perms = { net: ["fetch:https://api.example.com/**"] };
131
- expect(isNetAllowed(perms, "https://api.example.com/v1/users")).toBe(true);
132
- expect(isNetAllowed(perms, "https://exfil.example.com/x")).toBe(false);
132
+ describe("definePlugin", () => {
133
+ test("returns the input unchanged on success", () => {
134
+ const def = definePlugin({
135
+ name: "my-plugin",
136
+ version: "1.0.0",
137
+ description: "demo",
138
+ });
139
+ expect(def.name).toBe("my-plugin");
140
+ expect(def.description).toBe("demo");
133
141
  });
134
142
 
135
- test("isNetAllowed: wildcard subdomain", () => {
136
- const perms = { net: ["fetch:https://*.example.com/**"] };
137
- expect(isNetAllowed(perms, "https://api.example.com/v1/x")).toBe(true);
138
- expect(isNetAllowed(perms, "https://other.example.com/x")).toBe(true);
139
- expect(isNetAllowed(perms, "https://malicious.com/x")).toBe(false);
143
+ test("throws on invalid manifest", () => {
144
+ expect(() => definePlugin({ name: "MyPlugin", version: "1.0.0" } as never)).toThrow(
145
+ PluginSdkError,
146
+ );
140
147
  });
141
148
 
142
- test("plugin retains its declared permissions", () => {
143
- const plugin = definePlugin({
144
- name: "p",
145
- version: "1",
146
- permissions: {
147
- fs: ["read:./local/**"],
148
- net: ["fetch:https://api.example.com/**"],
149
+ test("carries contribution types through", () => {
150
+ const def = definePlugin({
151
+ name: "my-plugin",
152
+ version: "1.0.0",
153
+ contributions: {
154
+ graders: [
155
+ {
156
+ id: "my-grader",
157
+ async grade() {
158
+ return { pass: true };
159
+ },
160
+ },
161
+ ],
149
162
  },
150
163
  });
151
- expect(plugin.permissions?.fs?.[0]).toBe("read:./local/**");
152
- expect(plugin.permissions?.net?.[0]).toBe("fetch:https://api.example.com/**");
164
+ expect(def.contributions?.graders?.length).toBe(1);
165
+ });
166
+ });
167
+
168
+ describe("canonicalJson", () => {
169
+ test("serialises primitives", () => {
170
+ expect(canonicalJson(null)).toBe("null");
171
+ expect(canonicalJson(true)).toBe("true");
172
+ expect(canonicalJson(false)).toBe("false");
173
+ expect(canonicalJson(42)).toBe("42");
174
+ expect(canonicalJson("hi")).toBe('"hi"');
175
+ });
176
+
177
+ test("rejects non-finite numbers", () => {
178
+ expect(() => canonicalJson(Number.NaN)).toThrow(PluginSdkError);
179
+ expect(() => canonicalJson(Number.POSITIVE_INFINITY)).toThrow(PluginSdkError);
180
+ });
181
+
182
+ test("sorts object keys deterministically", () => {
183
+ const a = canonicalJson({ z: 1, a: 2, m: 3 });
184
+ const b = canonicalJson({ a: 2, m: 3, z: 1 });
185
+ expect(a).toBe(b);
186
+ expect(a).toBe('{"a":2,"m":3,"z":1}');
187
+ });
188
+
189
+ test("recurses into nested objects + arrays", () => {
190
+ const out = canonicalJson({
191
+ list: [{ b: 2, a: 1 }, "str"],
192
+ obj: { z: { y: 1 } },
193
+ });
194
+ expect(out).toBe('{"list":[{"a":1,"b":2},"str"],"obj":{"z":{"y":1}}}');
195
+ });
196
+
197
+ test("drops undefined keys", () => {
198
+ const out = canonicalJson({ a: 1, b: undefined, c: 3 });
199
+ expect(out).toBe('{"a":1,"c":3}');
200
+ });
201
+ });
202
+
203
+ describe("manifestPayloadForSigning", () => {
204
+ test("excludes signature from the canonical payload", () => {
205
+ const manifest = {
206
+ name: "my-plugin",
207
+ version: "1.0.0",
208
+ signature: {
209
+ algorithm: "ed25519" as const,
210
+ publicKeyB64: "pk",
211
+ sigB64: "sig",
212
+ },
213
+ };
214
+ const payload = manifestPayloadForSigning(manifest);
215
+ expect(payload).not.toContain("signature");
216
+ expect(payload).not.toContain("ed25519");
217
+ expect(payload).toContain("my-plugin");
218
+ });
219
+
220
+ test("matches canonicalJson when no signature is present", () => {
221
+ const manifest = { name: "my-plugin", version: "1.0.0" };
222
+ expect(manifestPayloadForSigning(manifest)).toBe(canonicalJson(manifest));
223
+ });
224
+
225
+ test("signing payload is stable across signature mutations", () => {
226
+ const base = { name: "my-plugin", version: "1.0.0", description: "demo" };
227
+ const signed1 = {
228
+ ...base,
229
+ signature: { algorithm: "ed25519" as const, publicKeyB64: "a", sigB64: "b" },
230
+ };
231
+ const signed2 = {
232
+ ...base,
233
+ signature: { algorithm: "ed25519" as const, publicKeyB64: "c", sigB64: "d" },
234
+ };
235
+ expect(manifestPayloadForSigning(signed1)).toBe(manifestPayloadForSigning(signed2));
153
236
  });
154
237
  });
package/src/index.ts CHANGED
@@ -1,29 +1,39 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ import type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
4
+
1
5
  /**
2
- * Catalog F5 `plugin-sdk` — Section 26 Studio.
6
+ * Section 41 — `@crewhaus/plugin-sdk` v2.
7
+ *
8
+ * Public typed surface for third-party plugins. A plugin is a single
9
+ * TS/JS module exporting `definePlugin({ … })`. The §41 `plugin-loader`
10
+ * loads + activates plugins at runtime; the §42 `plugin-registry`
11
+ * discovers them; the §40 sigstore-style signature verification runs
12
+ * before either.
3
13
  *
4
- * Typed surface for third-party studio plugins. A plugin is a single
5
- * TS module exporting `definePlugin({...})`; the studio-server
6
- * lazy-loads them from `~/.crewhaus/plugins/<name>/index.ts` at boot
7
- * (or on hot-reload).
14
+ * v2 widens the v1 surface (Studio-only see `crewhaus/utilities/plugin-sdk`)
15
+ * to cover the five extension points the catalog cares about:
8
16
  *
9
- * v0 hooks:
10
- * - `onSpecLoad(spec)` observer; can return a side-pane
11
- * contribution to inject into the UI
12
- * - `onTraceEvent(event)` observer; called for every event
13
- * streamed over SSE
14
- * - `onEvalSampleRendered(sample)`observer; called when an eval
15
- * sample is being prepared for the
16
- * UI panel
17
+ * 1. **Tools** — anything you would otherwise pass to `buildTool()`.
18
+ * 2. **Channels** `ChannelAdapter`-shaped inbound surface (Slack,
19
+ * Telegram, plus future plugins like Mastodon, IRC, etc.).
20
+ * 3. **Models** provider adapters that match the canonical
21
+ * `ProviderAdapter` contract from `adapter-anthropic`.
22
+ * 4. **Graders**evaluators that match the `RegisteredGrader`
23
+ * contract from `grader-registry`.
24
+ * 5. **Target emitters** — compile-time target backends that match
25
+ * the `Emitter` contract from `compiler-core`.
17
26
  *
18
- * v0 contributions: a plugin can declare `panes` UI tabs the studio-
19
- * ui adds to its sidebar defined as `{ id, title, html }`. The HTML
20
- * is rendered as innerHTML inside an iframe-shaped container; v0 ships
21
- * a path-based sandbox (file-system reads outside `~/.crewhaus/plugins/
22
- * <self>/` are rejected at load-time via `loadPlugin`'s allowlist) but
23
- * NOT script isolation (deferred proper isolation requires a worker
24
- * or QuickJS sandbox).
27
+ * The contributions are *declarations*; `plugin-loader` is responsible
28
+ * for wiring each declaration into the host's registry at runtime
29
+ * (and for enforcing the `permissions` allow-list).
30
+ *
31
+ * The SDK is intentionally **dependency-light**: it only imports
32
+ * `@crewhaus/errors` + `@crewhaus/tool-catalog` (to expose the
33
+ * `ToolDefinition` type plugins already know). Other contract types
34
+ * are re-exported as structural shapes so a plugin author doesn't
35
+ * have to pull in five workspace packages just to declare a manifest.
25
36
  */
26
- import { CrewhausError } from "@crewhaus/errors";
27
37
 
28
38
  export class PluginSdkError extends CrewhausError {
29
39
  override readonly name = "PluginSdkError";
@@ -32,187 +42,316 @@ export class PluginSdkError extends CrewhausError {
32
42
  }
33
43
  }
34
44
 
35
- export type StudioPluginPane = {
45
+ // ---------------------------------------------------------------------------
46
+ // Re-exported contract types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export type { RegisteredTool, ToolDefinition } from "@crewhaus/tool-catalog";
50
+
51
+ /**
52
+ * Structural shape of a channel adapter contribution. Matches the
53
+ * `ChannelAdapter` interface duplicated across `channel-adapter-slack`
54
+ * / `-telegram` / `-discord` / `-whatsapp` / `-imessage`. Plugins
55
+ * implement this shape directly; `plugin-loader` adapts it into the
56
+ * channel registry slot for the target shape.
57
+ */
58
+ export interface PluginChannelAdapter {
36
59
  readonly id: string;
37
- readonly title: string;
38
- /**
39
- * Static HTML the studio-ui injects into the pane's container. Plugins
40
- * that want dynamic behaviour can include a <script> tag — but see the
41
- * sandbox notes above.
42
- */
43
- readonly html: string;
44
- };
60
+ verify(req: { headers: Headers; body: string }): Promise<boolean>;
61
+ parseInbound(req: { headers: Headers; body: string }): Promise<unknown>;
62
+ sendReply(args: {
63
+ channelId: string;
64
+ threadKey?: string;
65
+ text: string;
66
+ [k: string]: unknown;
67
+ }): Promise<void>;
68
+ setTyping?(args: { channelId: string; on: boolean }): Promise<void>;
69
+ }
45
70
 
46
- export type StudioPluginHooks = {
47
- /** Called when the studio loads a spec for editing/inspection. */
48
- onSpecLoad?(spec: { name: string; target: string; raw: string }): void;
49
- /** Called for every TraceEvent streamed over SSE for a live run. */
50
- onTraceEvent?(event: { kind: string; [k: string]: unknown }): void;
51
- /** Called when an eval sample is being prepared for the UI panel. */
52
- onEvalSampleRendered?(sample: { id: string; passed: boolean; [k: string]: unknown }): void;
53
- };
71
+ /**
72
+ * Structural shape of a provider adapter. Mirrors the `ProviderAdapter`
73
+ * exported from `adapter-anthropic` without forcing the SDK to import
74
+ * that package's transitive deps. The full provider request / stream
75
+ * event types live in `adapter-anthropic`; plugins implementing this
76
+ * type should import those for accurate parameter shapes.
77
+ */
78
+ export interface PluginModelAdapter {
79
+ readonly id: string;
80
+ readonly features: {
81
+ readonly caching?: boolean;
82
+ readonly tool_use?: boolean;
83
+ readonly vision?: boolean;
84
+ readonly thinking?: boolean;
85
+ readonly web_search?: boolean;
86
+ };
87
+ stream(request: unknown): AsyncIterable<unknown>;
88
+ countTokens?(messages: unknown): Promise<number>;
89
+ }
54
90
 
55
91
  /**
56
- * Section 31 declared permissions schema. Plugins state up-front
57
- * exactly which filesystem paths they need to read (relative to their
58
- * sandbox root) and which network origins they can fetch. Runtime
59
- * enforces the allow-list — any I/O outside the declared scope is
60
- * blocked by the sandbox.
61
- *
62
- * Format:
63
- * fs: ["read:~/.crewhaus/plugins/<self>/data/**", "read:./fixtures/**"]
64
- * net: ["fetch:https://api.example.com/**", "fetch:https://*.example.com/**"]
65
- *
66
- * Patterns are minimatch-style globs against either filesystem paths
67
- * (relative to the plugin's sandbox root) or URL prefixes. The runtime
68
- * evaluator (`isFsAllowed` / `isNetAllowed`) is pure-string and
69
- * deterministic so callers can audit exactly what each plugin can do
70
- * before instantiating its sandbox.
92
+ * Structural shape of a grader contribution. Matches `RegisteredGrader`
93
+ * from `grader-registry`.
94
+ */
95
+ export interface PluginGrader {
96
+ readonly id: string;
97
+ readonly description?: string;
98
+ grade(sample: { input: unknown; output: unknown; expected?: unknown }): Promise<{
99
+ pass: boolean;
100
+ score?: number;
101
+ notes?: string;
102
+ }>;
103
+ }
104
+
105
+ /**
106
+ * Structural shape of a target emitter contribution. Matches the
107
+ * `Emitter` contract from `compiler-core` — a function that takes the
108
+ * IR variant and returns a `Bundle` (file list).
109
+ */
110
+ export interface PluginTargetEmitter {
111
+ readonly targetShape: string;
112
+ emit(ir: unknown): {
113
+ readonly files: ReadonlyArray<{ readonly path: string; readonly contents: string }>;
114
+ };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Manifest shape
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Capability declarations. Fail-closed — an undefined section means the
123
+ * plugin has zero access to that resource class.
71
124
  */
72
125
  export type PluginPermissions = {
126
+ /** Filesystem allow-list (minimatch globs relative to the plugin's sandbox root). */
73
127
  readonly fs?: ReadonlyArray<string>;
128
+ /** URL prefix allow-list for `fetch()` from inside the plugin. */
74
129
  readonly net?: ReadonlyArray<string>;
130
+ /** Names of host-provided tools the plugin's tools may call. */
131
+ readonly tools?: ReadonlyArray<string>;
132
+ /** Env-var names the plugin is permitted to read via the host's `secrets-manager`. */
133
+ readonly secrets?: ReadonlyArray<string>;
75
134
  };
76
135
 
77
- export type StudioPluginDefinition = {
136
+ export type PluginSignatureAlgorithm = "ed25519";
137
+
138
+ /**
139
+ * Detached signature over the canonical-JSON serialisation of the
140
+ * manifest with `signature` set to `undefined`. Verified by §42
141
+ * `plugin-registry` before any source is read.
142
+ */
143
+ export type PluginSignature = {
144
+ readonly algorithm: PluginSignatureAlgorithm;
145
+ readonly publicKeyB64: string;
146
+ readonly sigB64: string;
147
+ /** Optional ISO-8601 timestamp; advisory only. */
148
+ readonly issuedAt?: string;
149
+ };
150
+
151
+ export type PluginContributions = {
152
+ readonly tools?: ReadonlyArray<RegisteredTool | ToolDefinition>;
153
+ readonly channels?: ReadonlyArray<PluginChannelAdapter>;
154
+ readonly models?: ReadonlyArray<PluginModelAdapter>;
155
+ readonly graders?: ReadonlyArray<PluginGrader>;
156
+ readonly targetEmitters?: ReadonlyArray<PluginTargetEmitter>;
157
+ };
158
+
159
+ export type PluginManifest = {
160
+ /** Globally-unique kebab-case plugin id. */
78
161
  readonly name: string;
79
- /** Semver-shaped version string. Studio renders this in the plugins panel. */
162
+ /** Semver-shaped version string (validated by `validatePluginManifest`). */
80
163
  readonly version: string;
81
- readonly hooks?: StudioPluginHooks;
82
- readonly panes?: ReadonlyArray<StudioPluginPane>;
83
- /**
84
- * Optional one-line description shown in the plugins panel.
85
- */
86
164
  readonly description?: string;
165
+ readonly author?: string;
166
+ readonly homepage?: string;
167
+ readonly license?: string;
168
+ /** Minimum crewhaus runtime version this plugin requires (semver range). */
169
+ readonly engines?: { readonly crewhaus?: string };
170
+ readonly permissions?: PluginPermissions;
171
+ readonly contributions?: PluginContributions;
87
172
  /**
88
- * Section 31 declared permissions allow-list. Optional; absent
89
- * means "no fs / no net access" (fail-closed).
173
+ * Lowercase hex SHA-256 of the plugin's entrypoint (`index.js`). It is part
174
+ * of the manifest, so `manifestPayloadForSigning` includes it and the
175
+ * signature therefore commits to the CODE, not just the metadata. The loader
176
+ * refuses to `import()` an entrypoint whose hash does not match. Optional for
177
+ * back-compat; signed plugins SHOULD set it (compute via `entrypointDigest`).
90
178
  */
91
- readonly permissions?: PluginPermissions;
179
+ readonly entrypointDigest?: string;
180
+ readonly signature?: PluginSignature;
92
181
  };
93
182
 
94
- /**
95
- * Type-only helper. Plugins call:
96
- * export default definePlugin({ name, version, hooks, panes });
97
- */
98
- export function definePlugin(def: StudioPluginDefinition): StudioPluginDefinition {
99
- if (typeof def.name !== "string" || def.name.length === 0) {
100
- throw new PluginSdkError("definePlugin: `name` is required");
183
+ // ---------------------------------------------------------------------------
184
+ // Validation
185
+ // ---------------------------------------------------------------------------
186
+
187
+ const NAME_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
188
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[\w.+-]+)?(?:\+[\w.-]+)?$/;
189
+
190
+ function assertString(value: unknown, field: string): asserts value is string {
191
+ if (typeof value !== "string" || value.length === 0) {
192
+ throw new PluginSdkError(`plugin manifest: \`${field}\` must be a non-empty string`);
101
193
  }
102
- if (typeof def.version !== "string" || def.version.length === 0) {
103
- throw new PluginSdkError("definePlugin: `version` is required");
194
+ }
195
+
196
+ function assertOptionalString(value: unknown, field: string): asserts value is string | undefined {
197
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
198
+ throw new PluginSdkError(
199
+ `plugin manifest: \`${field}\` must be a non-empty string when present`,
200
+ );
201
+ }
202
+ }
203
+
204
+ function assertOptionalStringArray(
205
+ value: unknown,
206
+ field: string,
207
+ ): asserts value is ReadonlyArray<string> | undefined {
208
+ if (value === undefined) return;
209
+ if (!Array.isArray(value)) {
210
+ throw new PluginSdkError(`plugin manifest: \`${field}\` must be an array of strings`);
104
211
  }
105
- const ids = new Set<string>();
106
- for (const p of def.panes ?? []) {
107
- if (ids.has(p.id)) {
108
- throw new PluginSdkError(`definePlugin "${def.name}": duplicate pane id "${p.id}"`);
212
+ for (const item of value) {
213
+ if (typeof item !== "string" || item.length === 0) {
214
+ throw new PluginSdkError(`plugin manifest: \`${field}\` entries must be non-empty strings`);
109
215
  }
110
- ids.add(p.id);
111
216
  }
112
- // Section 31 — validate the declared permissions schema.
113
- validatePermissions(def.permissions);
114
- return Object.freeze({ ...def });
115
217
  }
116
218
 
117
219
  /**
118
- * Path-sandbox guard: a plugin loader is given a root directory
119
- * (`~/.crewhaus/plugins/<self>/`) and must resolve all imports inside
120
- * that root. This helper is exposed so the loader can reject a plugin
121
- * whose `definePlugin({...})` body smuggles file-path strings outside
122
- * the sandbox boundary (e.g. an exfil attempt via a pane's html).
123
- *
124
- * v0 only checks the plugin's declared `panes[].html` for `file://`
125
- * URLs that escape the sandbox; full content-sandbox isolation lands
126
- * in a follow-up.
220
+ * Throw `PluginSdkError` if `m` is not a valid `PluginManifest`. Returns
221
+ * the input typed as `PluginManifest` on success (used as a type guard).
127
222
  */
128
- export function assertPluginPathsStaySandboxed(
129
- plugin: StudioPluginDefinition,
130
- sandboxRoot: string,
131
- ): void {
132
- const root = sandboxRoot.endsWith("/") ? sandboxRoot.slice(0, -1) : sandboxRoot;
133
- for (const pane of plugin.panes ?? []) {
134
- const fileUrls = pane.html.match(/file:\/\/\S+/g) ?? [];
135
- for (const u of fileUrls) {
136
- const path = u.replace(/^file:\/\//, "");
137
- if (!path.startsWith(root)) {
138
- throw new PluginSdkError(
139
- `plugin "${plugin.name}" pane "${pane.id}" references file:// path outside its sandbox root: ${path}`,
140
- );
141
- }
223
+ export function validatePluginManifest(m: unknown): PluginManifest {
224
+ if (m === null || typeof m !== "object") {
225
+ throw new PluginSdkError("plugin manifest must be an object");
226
+ }
227
+ const manifest = m as Record<string, unknown>;
228
+ assertString(manifest["name"], "name");
229
+ if (!NAME_PATTERN.test(manifest["name"])) {
230
+ throw new PluginSdkError(
231
+ `plugin manifest: \`name\` must be 3-64 chars, lowercase a-z / 0-9 / "-", start with a letter, no trailing hyphen (got "${manifest["name"]}")`,
232
+ );
233
+ }
234
+ assertString(manifest["version"], "version");
235
+ if (!SEMVER_PATTERN.test(manifest["version"])) {
236
+ throw new PluginSdkError(
237
+ `plugin manifest: \`version\` must be semver-shaped (got "${manifest["version"]}")`,
238
+ );
239
+ }
240
+ assertOptionalString(manifest["description"], "description");
241
+ assertOptionalString(manifest["author"], "author");
242
+ assertOptionalString(manifest["homepage"], "homepage");
243
+ assertOptionalString(manifest["license"], "license");
244
+
245
+ if (manifest["entrypointDigest"] !== undefined) {
246
+ assertString(manifest["entrypointDigest"], "entrypointDigest");
247
+ if (!/^[a-f0-9]{64}$/.test(manifest["entrypointDigest"] as string)) {
248
+ throw new PluginSdkError(
249
+ "plugin manifest: `entrypointDigest` must be a lowercase hex SHA-256 (64 chars)",
250
+ );
251
+ }
252
+ }
253
+
254
+ if (manifest["engines"] !== undefined) {
255
+ const engines = manifest["engines"];
256
+ if (engines === null || typeof engines !== "object") {
257
+ throw new PluginSdkError("plugin manifest: `engines` must be an object");
258
+ }
259
+ assertOptionalString((engines as Record<string, unknown>)["crewhaus"], "engines.crewhaus");
260
+ }
261
+
262
+ if (manifest["permissions"] !== undefined) {
263
+ const perms = manifest["permissions"];
264
+ if (perms === null || typeof perms !== "object") {
265
+ throw new PluginSdkError("plugin manifest: `permissions` must be an object");
266
+ }
267
+ const p = perms as Record<string, unknown>;
268
+ assertOptionalStringArray(p["fs"], "permissions.fs");
269
+ assertOptionalStringArray(p["net"], "permissions.net");
270
+ assertOptionalStringArray(p["tools"], "permissions.tools");
271
+ assertOptionalStringArray(p["secrets"], "permissions.secrets");
272
+ }
273
+
274
+ if (manifest["signature"] !== undefined) {
275
+ const sig = manifest["signature"];
276
+ if (sig === null || typeof sig !== "object") {
277
+ throw new PluginSdkError("plugin manifest: `signature` must be an object");
278
+ }
279
+ const s = sig as Record<string, unknown>;
280
+ if (s["algorithm"] !== "ed25519") {
281
+ throw new PluginSdkError('plugin manifest: `signature.algorithm` must be "ed25519"');
142
282
  }
283
+ assertString(s["publicKeyB64"], "signature.publicKeyB64");
284
+ assertString(s["sigB64"], "signature.sigB64");
285
+ assertOptionalString(s["issuedAt"], "signature.issuedAt");
143
286
  }
287
+
288
+ return manifest as unknown as PluginManifest;
144
289
  }
145
290
 
146
291
  /**
147
- * Section 31 — content-sandbox runtime checks. The two helpers below
148
- * accept a plugin's declared permissions and return whether a given
149
- * fs path / network URL is permitted. The runtime caller wires this
150
- * into the actual sandbox boundary (Web Worker postMessage gate for UI
151
- * plugins, VM2-style realm for server plugins) so attempts to read
152
- * `/etc/passwd` or fetch `https://exfil.example.com/...` are blocked
153
- * at the sandbox edge.
154
- *
155
- * Pattern matching:
156
- * - `read:<path-glob>` — matches absolute or sandbox-relative paths.
157
- * Globs use `**` for recursive and `*` for single-segment.
158
- * - `fetch:<url-glob>` — matches request URLs by prefix + glob.
292
+ * Type-only helper plugins use:
293
+ * export default definePlugin({ name, version, contributions, });
159
294
  *
160
- * Empty / undefined permissions = fail-closed (deny all).
295
+ * Runs `validatePluginManifest` so misconfiguration fails at load time
296
+ * (before the plugin's tools are exposed to a host).
161
297
  */
162
- export function isFsAllowed(perms: PluginPermissions | undefined, path: string): boolean {
163
- if (!perms?.fs) return false;
164
- const patterns = perms.fs
165
- .filter((p) => p.startsWith("read:"))
166
- .map((p) => p.slice("read:".length));
167
- return patterns.some((pat) => globMatch(pat, path));
298
+ export function definePlugin<T extends PluginManifest>(def: T): T {
299
+ validatePluginManifest(def);
300
+ return def;
168
301
  }
169
302
 
170
- export function isNetAllowed(perms: PluginPermissions | undefined, url: string): boolean {
171
- if (!perms?.net) return false;
172
- const patterns = perms.net
173
- .filter((p) => p.startsWith("fetch:"))
174
- .map((p) => p.slice("fetch:".length));
175
- return patterns.some((pat) => globMatch(pat, url));
303
+ // ---------------------------------------------------------------------------
304
+ // Canonical JSON (for signature payload)
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * Deterministic JSON serialisation: sorted keys, no whitespace, `undefined`
309
+ * keys omitted. This is the byte string that the `signature` is computed
310
+ * over (with `signature` itself set to `undefined`).
311
+ *
312
+ * Mirrors the §40 template-marketplace-client canonical-JSON convention
313
+ * so plugin signatures verify with the same crypto primitive.
314
+ */
315
+ export function canonicalJson(value: unknown): string {
316
+ if (value === null) return "null";
317
+ if (typeof value === "boolean") return value ? "true" : "false";
318
+ if (typeof value === "number") {
319
+ if (!Number.isFinite(value)) {
320
+ throw new PluginSdkError("canonical JSON: non-finite numbers are not representable");
321
+ }
322
+ return JSON.stringify(value);
323
+ }
324
+ if (typeof value === "string") return JSON.stringify(value);
325
+ if (Array.isArray(value)) {
326
+ return `[${value.map((v) => canonicalJson(v)).join(",")}]`;
327
+ }
328
+ if (typeof value === "object") {
329
+ const obj = value as Record<string, unknown>;
330
+ const keys = Object.keys(obj)
331
+ .filter((k) => obj[k] !== undefined)
332
+ .sort();
333
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
334
+ }
335
+ throw new PluginSdkError(`canonical JSON: unsupported type ${typeof value}`);
176
336
  }
177
337
 
178
338
  /**
179
- * Tiny minimatch shim supports `**` (recursive), `*` (segment), and
180
- * literal-character matching. Sufficient for the plugin-permission
181
- * use case; for a more sophisticated patterning need we'd swap in
182
- * minimatch proper.
339
+ * Returns the byte string the plugin's signature should verify against
340
+ * the manifest's canonical JSON with `signature` cleared.
183
341
  */
184
- function globMatch(pattern: string, value: string): boolean {
185
- // Convert glob to a regex anchored at start + end.
186
- const escaped = pattern
187
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
188
- // ** must come BEFORE * — otherwise "*" replaces inside "**" first.
189
- .replace(/\*\*/g, "__GLOB_DOUBLE_STAR__")
190
- .replace(/\*/g, "[^/]*")
191
- .replace(/__GLOB_DOUBLE_STAR__/g, ".*");
192
- const re = new RegExp(`^${escaped}$`);
193
- return re.test(value);
342
+ export function manifestPayloadForSigning(manifest: PluginManifest): string {
343
+ // Shallow clone, drop signature, then canonical-encode. `entrypointDigest`
344
+ // is NOT dropped, so the signature commits to the plugin code via its hash.
345
+ const { signature: _signature, ...rest } = manifest;
346
+ return canonicalJson(rest);
194
347
  }
195
348
 
196
349
  /**
197
- * Section 31 assert a plugin's declared permissions match the
198
- * actually-needed scope. Used at plugin-load time so a plugin that
199
- * declares overly broad permissions fails to load.
200
- *
201
- * v0 just runs structural validation (fs entries start with `read:`,
202
- * net entries start with `fetch:`); a follow-up adds policy-engine
203
- * integration so admins can refuse plugins whose declared net allow-
204
- * list includes broad wildcards.
350
+ * Compute the `entrypointDigest` for plugin code the lowercase hex SHA-256 of
351
+ * the entrypoint file's bytes. Plugin signing tools set the result on the
352
+ * manifest's `entrypointDigest` before signing; the loader recomputes it from
353
+ * the on-disk `index.js` and refuses to import on mismatch.
205
354
  */
206
- export function validatePermissions(perms: PluginPermissions | undefined): void {
207
- if (!perms) return;
208
- for (const f of perms.fs ?? []) {
209
- if (!f.startsWith("read:")) {
210
- throw new PluginSdkError(`fs permission "${f}" must start with "read:"`);
211
- }
212
- }
213
- for (const n of perms.net ?? []) {
214
- if (!n.startsWith("fetch:")) {
215
- throw new PluginSdkError(`net permission "${n}" must start with "fetch:"`);
216
- }
217
- }
355
+ export function entrypointDigest(code: string | Uint8Array): string {
356
+ return createHash("sha256").update(code).digest("hex");
218
357
  }
package/README.md DELETED
@@ -1,98 +0,0 @@
1
- # `@crewhaus/plugin-sdk`
2
-
3
- Typed surface for third-party Studio plugins. A plugin is a single TS module exporting `definePlugin({...})`; [studio-server](../studio-server/) lazy-loads them from `~/.crewhaus/plugins/<name>/index.ts` at boot.
4
-
5
- ## Try it
6
-
7
- ```bash
8
- cd plugin-sdk
9
- bun install
10
- bun run start
11
- # → defines a sample plugin, validates it, runs permission probes:
12
- # ALLOW fs read:./data/foo.json
13
- # DENY fs read:./secrets/key
14
- # ALLOW net fetch:https://api.example.com/v1/users
15
- # DENY net fetch:https://malicious.com/
16
- ```
17
-
18
- ## Author a plugin
19
-
20
- Create `~/.crewhaus/plugins/my-plugin/index.ts`:
21
-
22
- ```typescript
23
- import { definePlugin } from "@crewhaus/plugin-sdk";
24
-
25
- export default definePlugin({
26
- name: "my-plugin",
27
- version: "0.1.0",
28
- description: "Adds a custom side-pane to the studio.",
29
-
30
- hooks: {
31
- onSpecLoad(spec) {
32
- // spec: { name, target, raw }
33
- },
34
- onTraceEvent(event) {
35
- // event: { kind, ... } — fires for every SSE event
36
- },
37
- onEvalSampleRendered(sample) {
38
- // sample: { id, passed, ... }
39
- },
40
- },
41
-
42
- panes: [
43
- {
44
- id: "my-pane",
45
- title: "My Pane",
46
- html: "<div>Hello from my plugin</div>",
47
- },
48
- ],
49
-
50
- permissions: {
51
- fs: ["read:~/.crewhaus/plugins/my-plugin/data/**"],
52
- net: ["fetch:https://api.example.com/**"],
53
- },
54
- });
55
- ```
56
-
57
- `definePlugin` validates the definition (`name`/`version` required, pane `id`s unique, permission entries prefixed with `read:` or `fetch:`) and freezes it. studio-server discovers the file when its `/api/plugins` endpoint scans `pluginRoot`.
58
-
59
- ## Permissions
60
-
61
- A plugin declares exactly which filesystem paths and network origins it needs:
62
-
63
- ```typescript
64
- permissions: {
65
- fs: ["read:./data/**", "read:~/.crewhaus/plugins/my-plugin/cache/**"],
66
- net: ["fetch:https://api.example.com/**", "fetch:https://*.example.com/**"],
67
- }
68
- ```
69
-
70
- Patterns are minimatch-style globs (`**` recursive, `*` single-segment). Empty or absent permissions = **fail-closed** (deny all). The runtime gates each I/O attempt with `isFsAllowed(perms, path)` and `isNetAllowed(perms, url)`.
71
-
72
- > v0 enforces the filesystem allowlist at plugin-load via [`assertPluginPathsStaySandboxed`](./src/index.ts) (rejects `file://` URLs in pane HTML that escape the sandbox root). Full script isolation (worker / QuickJS) lands in a follow-up.
73
-
74
- ## API surface
75
-
76
- | Export | Kind | Summary |
77
- |---|---|---|
78
- | `definePlugin(def)` | function | validates + freezes a plugin definition |
79
- | `assertPluginPathsStaySandboxed(plugin, root)` | function | throws if any pane HTML embeds a `file://` URL outside `root` |
80
- | `isFsAllowed(perms, path)` | function | true iff `path` matches an `fs: ["read:<glob>"]` entry |
81
- | `isNetAllowed(perms, url)` | function | true iff `url` matches a `net: ["fetch:<glob>"]` entry |
82
- | `validatePermissions(perms)` | function | structural check; throws `PluginSdkError` on bad prefixes |
83
- | `PluginSdkError` | class | thrown for any plugin-author mistake |
84
- | `StudioPluginDefinition` | type | what `definePlugin` accepts/returns |
85
- | `StudioPluginHooks` | type | `onSpecLoad`, `onTraceEvent`, `onEvalSampleRendered` |
86
- | `StudioPluginPane` | type | `{ id, title, html }` |
87
- | `PluginPermissions` | type | `{ fs?: string[], net?: string[] }` |
88
-
89
- ## Pairs with
90
-
91
- - [studio-server](../studio-server/) — discovers plugins from `pluginRoot`, calls `assertPluginPathsStaySandboxed`, exposes `/api/plugins`
92
- - [studio-ui](../studio-ui/) — renders each plugin's `panes[]` in the Plugins tab
93
-
94
- ## Related
95
-
96
- - Source: [src/index.ts](./src/index.ts), [src/scripts/start.ts](./src/scripts/start.ts)
97
-
98
- > Inside this workspace, resolves as `workspace:*`. Not yet on npm.
@@ -1,58 +0,0 @@
1
- /**
2
- * `bun run start` entry point for `@crewhaus/plugin-sdk`.
3
- *
4
- * Defines a sample studio plugin via `definePlugin`, validates its
5
- * permissions, and exercises the `isFsAllowed` / `isNetAllowed`
6
- * decision functions against a few representative paths/URLs.
7
- */
8
- import {
9
- definePlugin,
10
- isFsAllowed,
11
- isNetAllowed,
12
- validatePermissions,
13
- } from "../index";
14
-
15
- const demo = definePlugin({
16
- name: "demo-plugin",
17
- version: "0.1.0",
18
- description: "Sample plugin exercising the SDK surface.",
19
- hooks: {
20
- onSpecLoad(spec) {
21
- void spec; // observer
22
- },
23
- onTraceEvent(event) {
24
- void event; // observer
25
- },
26
- },
27
- panes: [
28
- {
29
- id: "demo-pane",
30
- title: "Demo Pane",
31
- html: "<div>Hello from demo-plugin</div>",
32
- },
33
- ],
34
- permissions: {
35
- fs: ["read:./data/**", "read:~/.crewhaus/plugins/demo-plugin/cache/**"],
36
- net: ["fetch:https://api.example.com/**", "fetch:https://*.example.com/**"],
37
- },
38
- });
39
-
40
- validatePermissions(demo.permissions);
41
-
42
- process.stdout.write(`✓ definePlugin() returned a frozen manifest for "${demo.name}"\n`);
43
- process.stdout.write(` version: ${demo.version}\n`);
44
- process.stdout.write(` description: ${demo.description}\n`);
45
- process.stdout.write(` hooks: ${Object.keys(demo.hooks ?? {}).join(", ") || "(none)"}\n`);
46
- process.stdout.write(` panes: ${demo.panes?.map((p) => p.id).join(", ") ?? "(none)"}\n`);
47
- process.stdout.write(` permissions: fs=${demo.permissions?.fs?.length ?? 0}, net=${demo.permissions?.net?.length ?? 0}\n`);
48
-
49
- process.stdout.write(`\nPermission probes:\n`);
50
- const checks: ReadonlyArray<readonly [string, () => boolean]> = [
51
- ["fs read:./data/foo.json", () => isFsAllowed(demo.permissions, "./data/foo.json")],
52
- ["fs read:./secrets/key", () => isFsAllowed(demo.permissions, "./secrets/key")],
53
- ["net fetch:https://api.example.com/v1/users", () => isNetAllowed(demo.permissions, "https://api.example.com/v1/users")],
54
- ["net fetch:https://malicious.com/", () => isNetAllowed(demo.permissions, "https://malicious.com/")],
55
- ];
56
- for (const [label, fn] of checks) {
57
- process.stdout.write(` ${fn() ? "ALLOW" : "DENY "} ${label}\n`);
58
- }