@crewhaus/plugin-sdk 0.1.1 → 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 +14 -16
- package/src/index.test.ts +196 -113
- package/src/index.ts +301 -162
- package/README.md +0 -98
- package/src/scripts/start.ts +0 -58
package/package.json
CHANGED
|
@@ -1,39 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/plugin-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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@
|
|
19
|
-
"url": "https://
|
|
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/
|
|
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/
|
|
29
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/plugin-sdk#readme",
|
|
27
30
|
"bugs": {
|
|
28
|
-
"url": "https://github.com/crewhaus/
|
|
31
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
29
32
|
},
|
|
30
33
|
"publishConfig": {
|
|
31
|
-
"access": "
|
|
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
|
-
|
|
4
|
+
canonicalJson,
|
|
5
5
|
definePlugin,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from "./index
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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("
|
|
17
|
+
test("accepts pre-release + build metadata in semver", () => {
|
|
29
18
|
expect(() =>
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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("
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
name: "
|
|
95
|
-
version: "1",
|
|
96
|
-
permissions: { fs: [
|
|
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
|
-
|
|
101
|
-
name: "
|
|
102
|
-
version: "1",
|
|
103
|
-
permissions: {
|
|
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("
|
|
109
|
-
|
|
110
|
-
|
|
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("
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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("
|
|
121
|
-
|
|
122
|
-
|
|
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("
|
|
126
|
-
expect(
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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("
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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("
|
|
143
|
-
const
|
|
144
|
-
name: "
|
|
145
|
-
version: "1",
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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(
|
|
152
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
|
179
|
+
readonly entrypointDigest?: string;
|
|
180
|
+
readonly signature?: PluginSignature;
|
|
92
181
|
};
|
|
93
182
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
-
*
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
*
|
|
180
|
-
*
|
|
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
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
|
207
|
-
|
|
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.
|
package/src/scripts/start.ts
DELETED
|
@@ -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
|
-
}
|