@polderlabs/bizar 5.2.0 → 5.3.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/bizar-dash/dist/assets/{MobileChat-TCputYzr.js → MobileChat-OidrxXR9.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js.map → MobileChat-OidrxXR9.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js → MobileSettings-B91gVFii.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js.map → MobileSettings-B91gVFii.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-DRDXfbBP.js → icons-Bd8piSb2.js} +18 -13
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
- package/bizar-dash/dist/assets/{main-xFpWMd32.css → main-3G6mOhiK.css} +1 -1
- package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
- package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js → mobile-CSsp30lr.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js.map → mobile-CSsp30lr.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js → mobile-DtwK7DOV.js} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js.map → mobile-DtwK7DOV.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js → useSlashCommands-DEkxEvOO.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js.map → useSlashCommands-DEkxEvOO.js.map} +1 -1
- package/bizar-dash/dist/index.html +5 -5
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/eval-store.mjs +55 -0
- package/bizar-dash/src/server/eval.mjs +63 -0
- package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
- package/bizar-dash/src/server/plugins/store.mjs +4 -0
- package/bizar-dash/src/server/routes/eval.mjs +42 -2
- package/bizar-dash/src/server/routes/plugins.mjs +44 -4
- package/bizar-dash/src/server/schedules-runner.mjs +61 -0
- package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
- package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
- package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +2 -2
- package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
- package/bizar-dash/src/web/components/Topbar.tsx +3 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
- package/bizar-dash/src/web/styles/main.css +139 -0
- package/bizar-dash/src/web/views/Doctor.tsx +3 -3
- package/bizar-dash/src/web/views/Eval.tsx +225 -25
- package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
- package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
- package/bizar-dash/src/web/views/Plugins.tsx +9 -3
- package/bizar-dash/src/web/views/Schedules.tsx +1 -1
- package/bizar-dash/tests/a11y/components.test.tsx +149 -0
- package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
- package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
- package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
- package/bizar-dash/tests/eval-csv.test.mjs +67 -0
- package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
- package/bizar-dash/tests/plugins-permissions.test.mjs +525 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/plugins-permissions.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.3.0 — Tests for plugin permission enforcement:
|
|
5
|
+
*
|
|
6
|
+
* 1. safeInvoke rejects method-level permission denials
|
|
7
|
+
* before any plugin code runs (sandbox-side check).
|
|
8
|
+
* 2. safeInvoke logs every allow/deny to the audit log
|
|
9
|
+
* (permission-audit.mjs).
|
|
10
|
+
* 3. The /api/plugins/:id/invoke route handler surfaces a
|
|
11
|
+
* 403 with the missing permissions for a denial.
|
|
12
|
+
* 4. The /api/plugins/:id/audit endpoint returns the audit
|
|
13
|
+
* log filtered by plugin id.
|
|
14
|
+
*
|
|
15
|
+
* Strategy:
|
|
16
|
+
* - For sandbox-level tests we write a minimal plugin source to
|
|
17
|
+
* a tmpdir and call `loadPlugin` directly with `methodSpecs`
|
|
18
|
+
* (the manifest's `exports` array).
|
|
19
|
+
* - For route-level tests we exercise the real Express route
|
|
20
|
+
* handler by materialising a `req`/`res` mock and calling the
|
|
21
|
+
* handler via `router.stack`. We drive it through `store.mjs`
|
|
22
|
+
* by materialising a fake installed plugin on disk under a
|
|
23
|
+
* private `BIZAR_PLUGIN_HOME` — this avoids any need to mock
|
|
24
|
+
* ESM namespace exports (which are frozen at runtime).
|
|
25
|
+
*/
|
|
26
|
+
import { test, describe, beforeEach, before, after } from 'node:test';
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import { writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { join, resolve, dirname } from 'node:path';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const SANDBOX = await import(resolve(REPO, 'src/server/plugins/sandbox.mjs'));
|
|
35
|
+
const AUDIT = await import(resolve(REPO, 'src/server/plugins/permission-audit.mjs'));
|
|
36
|
+
const ROUTES = await import(resolve(REPO, 'src/server/routes/plugins.mjs'));
|
|
37
|
+
const STORE = await import(resolve(REPO, 'src/server/plugins/store.mjs'));
|
|
38
|
+
|
|
39
|
+
const TMP = join(tmpdir(), `bizar-perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
40
|
+
mkdirSync(TMP, { recursive: true });
|
|
41
|
+
|
|
42
|
+
// Sandbox plugin home — materialised for the integration tests at
|
|
43
|
+
// the bottom of the file. Set BEFORE the store module reads the env
|
|
44
|
+
// var at first call.
|
|
45
|
+
const INTEGRATION_HOME = join(tmpdir(),
|
|
46
|
+
`bizar-perm-int-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
47
|
+
mkdirSync(INTEGRATION_HOME, { recursive: true });
|
|
48
|
+
process.env.BIZAR_PLUGIN_HOME = INTEGRATION_HOME;
|
|
49
|
+
|
|
50
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write a plugin source string to a tmp file, then load it through
|
|
54
|
+
* the real `loadPlugin`. Returns the loaded object.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} source
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {string[]} [opts.permissions]
|
|
59
|
+
* @param {Array<{ name: string, permissions?: string[] }>} [opts.methodSpecs]
|
|
60
|
+
* @param {string} [opts.pluginId]
|
|
61
|
+
*/
|
|
62
|
+
async function loadFromSource(source, opts = {}) {
|
|
63
|
+
const file = join(TMP, `plugin-${Math.random().toString(36).slice(2)}.js`);
|
|
64
|
+
writeFileSync(file, source);
|
|
65
|
+
return SANDBOX.loadPlugin({
|
|
66
|
+
mainFile: file,
|
|
67
|
+
config: opts.config || {},
|
|
68
|
+
permissions: opts.permissions || [],
|
|
69
|
+
pluginId: opts.pluginId || 'test-plugin',
|
|
70
|
+
pluginRoot: TMP,
|
|
71
|
+
timeoutMs: opts.timeoutMs || 5_000,
|
|
72
|
+
methodSpecs: opts.methodSpecs || [],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Minimal stand-in for the Express `req` object.
|
|
78
|
+
*/
|
|
79
|
+
function fakeReq(partial = {}) {
|
|
80
|
+
return {
|
|
81
|
+
params: {},
|
|
82
|
+
body: {},
|
|
83
|
+
query: {},
|
|
84
|
+
headers: {},
|
|
85
|
+
ip: '127.0.0.1',
|
|
86
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
87
|
+
...partial,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Minimal stand-in for the Express `res` object. Records every call
|
|
93
|
+
* to `status`, `json`, and `end` so tests can assert on the actual
|
|
94
|
+
* HTTP response that would have been sent. `res.json()` defaults the
|
|
95
|
+
* status code to 200 (matching Express's real behaviour) so tests
|
|
96
|
+
* can assert either `statusCode === 200` (success paths) or the
|
|
97
|
+
* explicit `status(403)` set by the handler (error paths).
|
|
98
|
+
*/
|
|
99
|
+
function fakeRes() {
|
|
100
|
+
const res = {
|
|
101
|
+
statusCode: null,
|
|
102
|
+
body: undefined,
|
|
103
|
+
ended: false,
|
|
104
|
+
status(code) {
|
|
105
|
+
res.statusCode = code;
|
|
106
|
+
return res;
|
|
107
|
+
},
|
|
108
|
+
json(payload) {
|
|
109
|
+
// Express's real `res.json` calls `res.send`, which sets the
|
|
110
|
+
// status to 200 by default if `res.status` wasn't called first.
|
|
111
|
+
if (res.statusCode === null) res.statusCode = 200;
|
|
112
|
+
res.body = payload;
|
|
113
|
+
res.ended = true;
|
|
114
|
+
return res;
|
|
115
|
+
},
|
|
116
|
+
end() {
|
|
117
|
+
res.ended = true;
|
|
118
|
+
return res;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
return res;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Find the first Express route handler registered for `method path`.
|
|
126
|
+
*/
|
|
127
|
+
function findRouteHandler(router, method, pathname) {
|
|
128
|
+
const layers = router.stack || [];
|
|
129
|
+
for (const layer of layers) {
|
|
130
|
+
if (!layer.route) continue;
|
|
131
|
+
const route = layer.route;
|
|
132
|
+
if (!route.methods[method]) continue;
|
|
133
|
+
if (route.path !== pathname) continue;
|
|
134
|
+
const handle = route.stack?.[0]?.handle;
|
|
135
|
+
if (typeof handle !== 'function') continue;
|
|
136
|
+
return { handler: handle, path: route.path };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Materialise an "installed plugin" on disk under our dedicated
|
|
143
|
+
* `INTEGRATION_HOME` so `store.invokePlugin` can load it for real.
|
|
144
|
+
* Writes both `plugin.json` (the manifest) AND `installed.json`
|
|
145
|
+
* (the registry that `store.getInstalled()` reads), otherwise the
|
|
146
|
+
* store would report `not_installed`.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} id
|
|
149
|
+
* @param {object} manifest full plugin.json contents
|
|
150
|
+
* @param {string} source plugin source code
|
|
151
|
+
* @returns {string} absolute path to the plugin's install dir
|
|
152
|
+
*/
|
|
153
|
+
function installFakePlugin(id, manifest, source) {
|
|
154
|
+
const pluginDir = join(INTEGRATION_HOME, id);
|
|
155
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
156
|
+
writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(manifest));
|
|
157
|
+
writeFileSync(join(pluginDir, manifest.main || 'index.js'), source);
|
|
158
|
+
|
|
159
|
+
// Append the plugin entry to installed.json so store.getInstalled
|
|
160
|
+
// can find it. We read any existing entries first to avoid clobbering
|
|
161
|
+
// plugins installed by earlier tests in the same file.
|
|
162
|
+
const installedPath = join(INTEGRATION_HOME, 'installed.json');
|
|
163
|
+
/** @type {{ plugins: Array<object> }} */
|
|
164
|
+
let doc = { plugins: [] };
|
|
165
|
+
try {
|
|
166
|
+
const existing = JSON.parse(readFileSync(installedPath, 'utf8'));
|
|
167
|
+
if (existing && Array.isArray(existing.plugins)) doc = existing;
|
|
168
|
+
} catch { /* file missing or malformed — start fresh */ }
|
|
169
|
+
doc.plugins = doc.plugins.filter((p) => p.id !== id);
|
|
170
|
+
doc.plugins.push({
|
|
171
|
+
id,
|
|
172
|
+
version: manifest.version || '1.0.0',
|
|
173
|
+
installedAt: new Date().toISOString(),
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
config: {},
|
|
176
|
+
permissions: manifest.permissions || [],
|
|
177
|
+
path: pluginDir,
|
|
178
|
+
});
|
|
179
|
+
writeFileSync(installedPath, JSON.stringify(doc, null, 2));
|
|
180
|
+
return pluginDir;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── lifecycle ────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
AUDIT.clearPermissionAuditLog();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
after(() => {
|
|
190
|
+
// Tear down scratch directories so we don't leave debris in /tmp.
|
|
191
|
+
try { rmSync(TMP, { recursive: true, force: true }); } catch { /* noop */ }
|
|
192
|
+
try { rmSync(INTEGRATION_HOME, { recursive: true, force: true }); } catch { /* noop */ }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── tests ────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe('plugin permissions — sandbox-side enforcement', () => {
|
|
198
|
+
test('safeInvoke rejects when required permission is missing', async () => {
|
|
199
|
+
const loaded = await loadFromSource(
|
|
200
|
+
`module.exports = { doSomething: async () => 'did-it' };`,
|
|
201
|
+
{
|
|
202
|
+
permissions: ['net'],
|
|
203
|
+
methodSpecs: [{ name: 'doSomething', permissions: ['fs'] }],
|
|
204
|
+
pluginId: 'perm-test-missing',
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
const r = await SANDBOX.safeInvoke(loaded, 'doSomething', []);
|
|
208
|
+
assert.equal(r.ok, false);
|
|
209
|
+
assert.equal(r.code, 'permission_denied');
|
|
210
|
+
assert.deepEqual(r.missing, ['fs']);
|
|
211
|
+
assert.match(r.error, /requires permissions: fs/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('safeInvoke rejects when ALL declared perms are missing', async () => {
|
|
215
|
+
const loaded = await loadFromSource(
|
|
216
|
+
`module.exports = { run: async () => 1 };`,
|
|
217
|
+
{
|
|
218
|
+
permissions: [],
|
|
219
|
+
methodSpecs: [{ name: 'run', permissions: ['net', 'fs:read'] }],
|
|
220
|
+
pluginId: 'perm-test-all-missing',
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
const r = await SANDBOX.safeInvoke(loaded, 'run', []);
|
|
224
|
+
assert.equal(r.ok, false);
|
|
225
|
+
assert.equal(r.code, 'permission_denied');
|
|
226
|
+
assert.deepEqual(r.missing, ['net', 'fs:read']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('safeInvoke allows when required permission is granted', async () => {
|
|
230
|
+
const loaded = await loadFromSource(
|
|
231
|
+
`module.exports = { echo: async (x) => 'echo:' + x };`,
|
|
232
|
+
{
|
|
233
|
+
// The sandbox's KNOWN_PERMS is `['net', 'fs:read']` — anything
|
|
234
|
+
// else is reported as `invalid`. Use the canonical name here
|
|
235
|
+
// so the `permissions` array is parsed to the granted set.
|
|
236
|
+
permissions: ['net', 'fs:read'],
|
|
237
|
+
methodSpecs: [{ name: 'echo', permissions: ['fs:read'] }],
|
|
238
|
+
pluginId: 'perm-test-allow',
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
const r = await SANDBOX.safeInvoke(loaded, 'echo', ['hi']);
|
|
242
|
+
assert.equal(r.ok, true);
|
|
243
|
+
assert.equal(r.result, 'echo:hi');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('safeInvoke skips the check when methodSpecs is empty (legacy plugins)', async () => {
|
|
247
|
+
// No methodSpecs at all — the plugin loaded without a manifest.
|
|
248
|
+
// Legacy behaviour must continue to work: bypass the check and
|
|
249
|
+
// run the method.
|
|
250
|
+
const loaded = await loadFromSource(
|
|
251
|
+
`module.exports = { go: async () => 'ok' };`,
|
|
252
|
+
{
|
|
253
|
+
permissions: [],
|
|
254
|
+
// methodSpecs omitted on purpose
|
|
255
|
+
pluginId: 'perm-test-legacy',
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
const r = await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
259
|
+
assert.equal(r.ok, true);
|
|
260
|
+
assert.equal(r.result, 'ok');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('safeInvoke allows undeclared methods even when perms are insufficient', async () => {
|
|
264
|
+
const loaded = await loadFromSource(
|
|
265
|
+
`module.exports = { declared: async () => 'a', undeclared: async () => 'b' };`,
|
|
266
|
+
{
|
|
267
|
+
permissions: ['net'],
|
|
268
|
+
methodSpecs: [{ name: 'declared', permissions: ['fs'] }],
|
|
269
|
+
pluginId: 'perm-test-partial',
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
const r1 = await SANDBOX.safeInvoke(loaded, 'declared', []);
|
|
273
|
+
assert.equal(r1.ok, false);
|
|
274
|
+
assert.equal(r1.code, 'permission_denied');
|
|
275
|
+
const r2 = await SANDBOX.safeInvoke(loaded, 'undeclared', []);
|
|
276
|
+
assert.equal(r2.ok, true);
|
|
277
|
+
assert.equal(r2.result, 'b');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('plugin permissions — audit log', () => {
|
|
282
|
+
test('logs a denied invocation with allowed=false', async () => {
|
|
283
|
+
const loaded = await loadFromSource(
|
|
284
|
+
`module.exports = { go: async () => 'should-not-run' };`,
|
|
285
|
+
{
|
|
286
|
+
permissions: ['net'],
|
|
287
|
+
methodSpecs: [{ name: 'go', permissions: ['fs'] }],
|
|
288
|
+
pluginId: 'audit-test-denied',
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
const r = await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
292
|
+
assert.equal(r.code, 'permission_denied');
|
|
293
|
+
const log = AUDIT.getPermissionAuditLog({ pluginId: 'audit-test-denied' });
|
|
294
|
+
assert.ok(log.length >= 1, 'audit log should have at least one entry');
|
|
295
|
+
const denied = log.find((e) => e.allowed === false);
|
|
296
|
+
assert.ok(denied, 'should have an allowed=false entry');
|
|
297
|
+
assert.equal(denied.method, 'go');
|
|
298
|
+
assert.equal(denied.permission, 'fs');
|
|
299
|
+
assert.equal(denied.pluginId, 'audit-test-denied');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('logs an allowed invocation with allowed=true', async () => {
|
|
303
|
+
const loaded = await loadFromSource(
|
|
304
|
+
`module.exports = { go: async () => 'ok' };`,
|
|
305
|
+
{
|
|
306
|
+
permissions: ['net', 'fs:read'],
|
|
307
|
+
methodSpecs: [{ name: 'go', permissions: ['fs:read'] }],
|
|
308
|
+
pluginId: 'audit-test-allowed',
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
312
|
+
const log = AUDIT.getPermissionAuditLog({ pluginId: 'audit-test-allowed' });
|
|
313
|
+
assert.ok(log.length >= 1);
|
|
314
|
+
const allowed = log.find((e) => e.allowed === true);
|
|
315
|
+
assert.ok(allowed, 'should have an allowed=true entry');
|
|
316
|
+
assert.equal(allowed.method, 'go');
|
|
317
|
+
assert.equal(allowed.permission, 'fs:read');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('audit log filters by plugin id', async () => {
|
|
321
|
+
const loadedA = await loadFromSource(
|
|
322
|
+
`module.exports = { go: async () => 'a' };`,
|
|
323
|
+
{
|
|
324
|
+
permissions: [],
|
|
325
|
+
methodSpecs: [{ name: 'go', permissions: ['net'] }],
|
|
326
|
+
pluginId: 'audit-A',
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
const loadedB = await loadFromSource(
|
|
330
|
+
`module.exports = { go: async () => 'b' };`,
|
|
331
|
+
{
|
|
332
|
+
permissions: [],
|
|
333
|
+
methodSpecs: [{ name: 'go', permissions: ['net'] }],
|
|
334
|
+
pluginId: 'audit-B',
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
await SANDBOX.safeInvoke(loadedA, 'go', []);
|
|
338
|
+
await SANDBOX.safeInvoke(loadedB, 'go', []);
|
|
339
|
+
const onlyA = AUDIT.getPermissionAuditLog({ pluginId: 'audit-A' });
|
|
340
|
+
for (const entry of onlyA) {
|
|
341
|
+
assert.equal(entry.pluginId, 'audit-A');
|
|
342
|
+
}
|
|
343
|
+
const onlyB = AUDIT.getPermissionAuditLog({ pluginId: 'audit-B' });
|
|
344
|
+
for (const entry of onlyB) {
|
|
345
|
+
assert.equal(entry.pluginId, 'audit-B');
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('audit log entries are newest-first', async () => {
|
|
350
|
+
const loaded = await loadFromSource(
|
|
351
|
+
`module.exports = { go: async () => 'ok' };`,
|
|
352
|
+
{
|
|
353
|
+
permissions: ['net'],
|
|
354
|
+
methodSpecs: [{ name: 'go', permissions: ['fs'] }],
|
|
355
|
+
pluginId: 'audit-test-order',
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
359
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
360
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
361
|
+
const log = AUDIT.getPermissionAuditLog({ pluginId: 'audit-test-order' });
|
|
362
|
+
assert.ok(log.length >= 3);
|
|
363
|
+
for (let i = 1; i < log.length; i++) {
|
|
364
|
+
const prev = new Date(log[i - 1].timestamp).getTime();
|
|
365
|
+
const cur = new Date(log[i].timestamp).getTime();
|
|
366
|
+
assert.ok(prev >= cur, `entry ${i} should be older than entry ${i - 1}`);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('clearPermissionAuditLog wipes the buffer', async () => {
|
|
371
|
+
const loaded = await loadFromSource(
|
|
372
|
+
`module.exports = { go: async () => 'ok' };`,
|
|
373
|
+
{
|
|
374
|
+
permissions: [],
|
|
375
|
+
methodSpecs: [{ name: 'go', permissions: ['net'] }],
|
|
376
|
+
pluginId: 'audit-test-clear',
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
380
|
+
assert.ok(AUDIT.getPermissionAuditLog({ pluginId: 'audit-test-clear' }).length >= 1);
|
|
381
|
+
AUDIT.clearPermissionAuditLog();
|
|
382
|
+
assert.equal(
|
|
383
|
+
AUDIT.getPermissionAuditLog({ pluginId: 'audit-test-clear' }).length,
|
|
384
|
+
0,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('limit clamps the number of returned entries', async () => {
|
|
389
|
+
const loaded = await loadFromSource(
|
|
390
|
+
`module.exports = { go: async () => 'ok' };`,
|
|
391
|
+
{
|
|
392
|
+
permissions: [],
|
|
393
|
+
methodSpecs: [{ name: 'go', permissions: ['net'] }],
|
|
394
|
+
pluginId: 'audit-test-limit',
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
for (let i = 0; i < 5; i++) {
|
|
398
|
+
await SANDBOX.safeInvoke(loaded, 'go', []);
|
|
399
|
+
}
|
|
400
|
+
const log = AUDIT.getPermissionAuditLog({
|
|
401
|
+
pluginId: 'audit-test-limit',
|
|
402
|
+
limit: 2,
|
|
403
|
+
});
|
|
404
|
+
assert.equal(log.length, 2);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('plugin permissions — HTTP route surface', () => {
|
|
409
|
+
test('POST /plugins/:id/invoke returns 403 with `missing` array for a denial', async () => {
|
|
410
|
+
// Materialise an installed plugin with a method whose declared
|
|
411
|
+
// permissions aren't granted. Going through `store.invokePlugin`
|
|
412
|
+
// exercises the real path the route uses, so the test verifies
|
|
413
|
+
// both the route's status-code mapping AND end-to-end behavior.
|
|
414
|
+
const PLUGIN_ID = 'route-deny';
|
|
415
|
+
installFakePlugin(
|
|
416
|
+
PLUGIN_ID,
|
|
417
|
+
{
|
|
418
|
+
id: PLUGIN_ID,
|
|
419
|
+
version: '1.0.0',
|
|
420
|
+
main: 'index.js',
|
|
421
|
+
permissions: ['net'],
|
|
422
|
+
exports: [{ name: 'doSomething', permissions: ['fs'] }],
|
|
423
|
+
},
|
|
424
|
+
`module.exports = { doSomething: async () => 'should-not-run' };`,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const router = ROUTES.createPluginsRouter();
|
|
428
|
+
const found = findRouteHandler(router, 'post', '/plugins/:id/invoke');
|
|
429
|
+
assert.ok(found, 'invoke route should be registered');
|
|
430
|
+
|
|
431
|
+
const req = fakeReq({
|
|
432
|
+
params: { id: PLUGIN_ID },
|
|
433
|
+
body: { method: 'doSomething', args: [] },
|
|
434
|
+
});
|
|
435
|
+
const res = fakeRes();
|
|
436
|
+
await found.handler(req, res);
|
|
437
|
+
assert.equal(res.statusCode, 403);
|
|
438
|
+
assert.equal(res.body.error, 'permission_denied');
|
|
439
|
+
assert.match(res.body.message, /requires permissions: fs/);
|
|
440
|
+
assert.deepEqual(res.body.missing, ['fs']);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('POST /plugins/:id/invoke returns 400 for non-permission usage errors', async () => {
|
|
444
|
+
// Use a non-existent plugin id so store.invokePlugin returns
|
|
445
|
+
// { ok: false, code: 'not_installed' } — exercises the 400 path.
|
|
446
|
+
const router = ROUTES.createPluginsRouter();
|
|
447
|
+
const found = findRouteHandler(router, 'post', '/plugins/:id/invoke');
|
|
448
|
+
assert.ok(found);
|
|
449
|
+
|
|
450
|
+
const req = fakeReq({
|
|
451
|
+
params: { id: 'no-such-plugin' },
|
|
452
|
+
body: { method: 'doSomething', args: [] },
|
|
453
|
+
});
|
|
454
|
+
const res = fakeRes();
|
|
455
|
+
await found.handler(req, res);
|
|
456
|
+
assert.equal(res.statusCode, 400);
|
|
457
|
+
assert.equal(res.body.error, 'not_installed');
|
|
458
|
+
assert.equal(res.ended, true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('GET /plugins/:id/audit returns audit log filtered by id (newest-first)', async () => {
|
|
462
|
+
// Seed two plugins' audit entries directly, then exercise the route.
|
|
463
|
+
AUDIT.logPermissionUse('route-test-plugin', 'foo', 'net', false);
|
|
464
|
+
AUDIT.logPermissionUse('route-test-plugin', 'foo', 'fs:read', true);
|
|
465
|
+
AUDIT.logPermissionUse('other-plugin', 'foo', 'net', false);
|
|
466
|
+
|
|
467
|
+
const router = ROUTES.createPluginsRouter();
|
|
468
|
+
const found = findRouteHandler(router, 'get', '/plugins/:id/audit');
|
|
469
|
+
assert.ok(found, 'audit route should be registered');
|
|
470
|
+
|
|
471
|
+
const req = fakeReq({ params: { id: 'route-test-plugin' } });
|
|
472
|
+
const res = fakeRes();
|
|
473
|
+
await found.handler(req, res);
|
|
474
|
+
assert.equal(res.statusCode, 200);
|
|
475
|
+
assert.ok(Array.isArray(res.body.audit));
|
|
476
|
+
assert.ok(res.body.audit.length >= 2);
|
|
477
|
+
for (const entry of res.body.audit) {
|
|
478
|
+
assert.equal(entry.pluginId, 'route-test-plugin');
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('GET /plugins/:id/audit honours ?limit=N', async () => {
|
|
483
|
+
for (let i = 0; i < 5; i++) {
|
|
484
|
+
AUDIT.logPermissionUse('limit-test', 'foo', 'net', i % 2 === 0);
|
|
485
|
+
}
|
|
486
|
+
const router = ROUTES.createPluginsRouter();
|
|
487
|
+
const found = findRouteHandler(router, 'get', '/plugins/:id/audit');
|
|
488
|
+
const req = fakeReq({
|
|
489
|
+
params: { id: 'limit-test' },
|
|
490
|
+
query: { limit: '3' },
|
|
491
|
+
});
|
|
492
|
+
const res = fakeRes();
|
|
493
|
+
await found.handler(req, res);
|
|
494
|
+
assert.equal(res.statusCode, 200);
|
|
495
|
+
assert.equal(res.body.audit.length, 3);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('plugin permissions — store integration', () => {
|
|
500
|
+
test('store.invokePlugin enforces method-level permissions end-to-end', async () => {
|
|
501
|
+
const PLUGIN_ID = 'store-end-to-end';
|
|
502
|
+
installFakePlugin(
|
|
503
|
+
PLUGIN_ID,
|
|
504
|
+
{
|
|
505
|
+
id: PLUGIN_ID,
|
|
506
|
+
version: '1.0.0',
|
|
507
|
+
main: 'index.js',
|
|
508
|
+
permissions: ['net'],
|
|
509
|
+
exports: [{ name: 'needsFS', permissions: ['fs:read'] }],
|
|
510
|
+
},
|
|
511
|
+
`module.exports = {
|
|
512
|
+
async needsFS() { return 'should-not-reach'; },
|
|
513
|
+
async publicMethod() { return 'public-ok'; }
|
|
514
|
+
};`,
|
|
515
|
+
);
|
|
516
|
+
const denied = await STORE.invokePlugin(PLUGIN_ID, 'needsFS', []);
|
|
517
|
+
assert.equal(denied.ok, false);
|
|
518
|
+
assert.equal(denied.code, 'permission_denied');
|
|
519
|
+
assert.deepEqual(denied.missing, ['fs:read']);
|
|
520
|
+
|
|
521
|
+
const allowed = await STORE.invokePlugin(PLUGIN_ID, 'publicMethod', []);
|
|
522
|
+
assert.equal(allowed.ok, true);
|
|
523
|
+
assert.equal(allowed.result, 'public-ok');
|
|
524
|
+
});
|
|
525
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|