@crewhaus/plugin-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/package.json +39 -0
- package/src/index.test.ts +154 -0
- package/src/index.ts +218 -0
- package/src/scripts/start.ts +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/plugin-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Studio plugin SDK — typed surface for third-party panes / hooks / renderers (Section 26 Studio)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "bun src/scripts/start.ts",
|
|
13
|
+
"test": "bun test src"
|
|
14
|
+
},
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Max Meier",
|
|
18
|
+
"email": "max@studiomax.io",
|
|
19
|
+
"url": "https://studiomax.io"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/crewhaus/utilities.git",
|
|
24
|
+
"directory": "plugin-sdk"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/crewhaus/utilities/tree/main/plugin-sdk#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/crewhaus/utilities/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "restricted"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"NOTICE"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
PluginSdkError,
|
|
4
|
+
assertPluginPathsStaySandboxed,
|
|
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
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rejects empty name / version", () => {
|
|
24
|
+
expect(() => definePlugin({ name: "", version: "1" })).toThrow(PluginSdkError);
|
|
25
|
+
expect(() => definePlugin({ name: "x", version: "" })).toThrow(PluginSdkError);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects duplicate pane ids within a plugin", () => {
|
|
29
|
+
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);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
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();
|
|
71
|
+
});
|
|
72
|
+
|
|
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/,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
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();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("plugin-sdk v1 — Section 31 content sandbox (T8)", () => {
|
|
91
|
+
test("definePlugin validates the permissions schema", () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
definePlugin({
|
|
94
|
+
name: "p",
|
|
95
|
+
version: "1",
|
|
96
|
+
permissions: { fs: ["this-is-not-prefixed"] },
|
|
97
|
+
}),
|
|
98
|
+
).toThrow(PluginSdkError);
|
|
99
|
+
expect(() =>
|
|
100
|
+
definePlugin({
|
|
101
|
+
name: "p",
|
|
102
|
+
version: "1",
|
|
103
|
+
permissions: { net: ["this-is-not-prefixed"] },
|
|
104
|
+
}),
|
|
105
|
+
).toThrow(PluginSdkError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("isFsAllowed: empty permissions = fail-closed", () => {
|
|
109
|
+
expect(isFsAllowed(undefined, "/etc/passwd")).toBe(false);
|
|
110
|
+
expect(isFsAllowed({}, "/etc/passwd")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
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);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("isFsAllowed: blocks /etc/passwd outside sandbox", () => {
|
|
121
|
+
const perms = { fs: ["read:/sandbox/**"] };
|
|
122
|
+
expect(isFsAllowed(perms, "/etc/passwd")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("isNetAllowed: empty permissions = fail-closed", () => {
|
|
126
|
+
expect(isNetAllowed(undefined, "https://example.com/x")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
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);
|
|
133
|
+
});
|
|
134
|
+
|
|
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);
|
|
140
|
+
});
|
|
141
|
+
|
|
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
|
+
},
|
|
150
|
+
});
|
|
151
|
+
expect(plugin.permissions?.fs?.[0]).toBe("read:./local/**");
|
|
152
|
+
expect(plugin.permissions?.net?.[0]).toBe("fetch:https://api.example.com/**");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog F5 `plugin-sdk` — Section 26 Studio.
|
|
3
|
+
*
|
|
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).
|
|
8
|
+
*
|
|
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
|
+
*
|
|
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).
|
|
25
|
+
*/
|
|
26
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
27
|
+
|
|
28
|
+
export class PluginSdkError extends CrewhausError {
|
|
29
|
+
override readonly name = "PluginSdkError";
|
|
30
|
+
constructor(message: string, cause?: unknown) {
|
|
31
|
+
super("config", message, cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type StudioPluginPane = {
|
|
36
|
+
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
|
+
};
|
|
45
|
+
|
|
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
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
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.
|
|
71
|
+
*/
|
|
72
|
+
export type PluginPermissions = {
|
|
73
|
+
readonly fs?: ReadonlyArray<string>;
|
|
74
|
+
readonly net?: ReadonlyArray<string>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type StudioPluginDefinition = {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
/** Semver-shaped version string. Studio renders this in the plugins panel. */
|
|
80
|
+
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
|
+
readonly description?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Section 31 — declared permissions allow-list. Optional; absent
|
|
89
|
+
* means "no fs / no net access" (fail-closed).
|
|
90
|
+
*/
|
|
91
|
+
readonly permissions?: PluginPermissions;
|
|
92
|
+
};
|
|
93
|
+
|
|
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");
|
|
101
|
+
}
|
|
102
|
+
if (typeof def.version !== "string" || def.version.length === 0) {
|
|
103
|
+
throw new PluginSdkError("definePlugin: `version` is required");
|
|
104
|
+
}
|
|
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}"`);
|
|
109
|
+
}
|
|
110
|
+
ids.add(p.id);
|
|
111
|
+
}
|
|
112
|
+
// Section 31 — validate the declared permissions schema.
|
|
113
|
+
validatePermissions(def.permissions);
|
|
114
|
+
return Object.freeze({ ...def });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
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.
|
|
127
|
+
*/
|
|
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
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.
|
|
159
|
+
*
|
|
160
|
+
* Empty / undefined permissions = fail-closed (deny all).
|
|
161
|
+
*/
|
|
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));
|
|
168
|
+
}
|
|
169
|
+
|
|
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));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
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.
|
|
183
|
+
*/
|
|
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);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
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.
|
|
205
|
+
*/
|
|
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
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}
|