Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# @toolctrl/plugin-runtime
|
||||
|
||||
Phase 06 Task #21 foundation — host-side runtime for WebAssembly plugins.
|
||||
|
||||
Runtime is staging/foundation only until external WASM sandbox security signoff; do not treat third-party plugin execution as production-enabled yet.
|
||||
|
||||
## Scope
|
||||
|
||||
- Manifest schema (Zod) with capability declarations
|
||||
- Ed25519 signature verification against a trusted-keys allowlist
|
||||
- WebAssembly sandbox wrapper on Node 22's built-in `WebAssembly`, with memory-page caps and host-function fuel charging
|
||||
- Capability gateway: deny-by-default host-function access
|
||||
- Hook registry/fanout contracts for the foundation hook set
|
||||
- Crash tracker, audit helpers, migrations guardrails, and lifecycle integration helpers
|
||||
|
||||
## Deferred
|
||||
|
||||
See `../../docs/features/plugins.md` and `../../docs/architecture/plugin-hooks.md` for
|
||||
current status. Still-deferred runtime work:
|
||||
|
||||
- Hot-reload (operator restarts pod)
|
||||
- Marketplace integration (#24 backlog)
|
||||
- Additional hook points beyond the registered foundation set
|
||||
- Higher-risk host bindings (`http.outbound`, `secret.read`, `db.read`, `db.write`) until security review/tests are complete
|
||||
- Plugin secret storage and encrypted settings bindings
|
||||
- Live debugging in plugin-dev mode
|
||||
|
||||
## Runtime choice
|
||||
|
||||
**Default: Node's built-in `WebAssembly` (V8) with memory-page limits and host-function fuel charging; per-instruction fuel remains deferred.**
|
||||
|
||||
Rationale: native Wasmtime bindings for Node (`@bytecodealliance/jco`, `@bytecodealliance/preview2-shim`) are still pre-1.0 and add a ~40 MB build asset. For the foundation we ship a portable wrapper around Node 22's `WebAssembly` runtime — sufficient for memory limits (`WebAssembly.Memory` max-page bounds) and an instruction-budget gate via host-fn fuel charging on each capability call.
|
||||
|
||||
**Fallback / upgrade path:** Wasmer (`@wasmer/sdk`) is API-compatible with the wrapper's contract and can be swapped in if we need richer per-instruction fuel. Wasmtime via `jco` becomes the production choice once the Component-Model is GA on Node. This is documented in `docs/architecture/plugin-system.md`.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@toolctrl/plugin-runtime",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./capabilities": "./src/capabilities.ts",
|
||||
"./hooks/registry": "./src/hooks/registry.ts",
|
||||
"./manifest": "./src/manifest.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run typecheck",
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@toolctrl/logger": "workspace:*",
|
||||
"@toolctrl/shared-store": "workspace:*",
|
||||
"semver": "^7.7.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CHAIN_FIELDS,
|
||||
canonicalisePluginCapabilityUseRow,
|
||||
computeRowHash,
|
||||
stampPluginCapabilityUseChain,
|
||||
verifyPluginCapabilityUseChain,
|
||||
type PluginCapabilityUseChainRow,
|
||||
type StampedRow,
|
||||
} from "./audit-chain.js";
|
||||
|
||||
function row(overrides: Partial<PluginCapabilityUseChainRow> = {}): PluginCapabilityUseChainRow {
|
||||
return {
|
||||
id: "row-1",
|
||||
pluginInvocationId: "inv-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: "com.acme.demo",
|
||||
capability: "log",
|
||||
payloadHash: null,
|
||||
outcome: "success",
|
||||
errorCode: null,
|
||||
durationMs: 12,
|
||||
timestamp: "2026-05-19T18:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canonicalisePluginCapabilityUseRow", () => {
|
||||
test("is deterministic and order-stable", () => {
|
||||
const a = canonicalisePluginCapabilityUseRow(row());
|
||||
const b = canonicalisePluginCapabilityUseRow(row());
|
||||
expect(a).toBe(b);
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("CHAIN_FIELDS has not been silently reordered", () => {
|
||||
// Guard against accidentally moving fields around — chain-breaking.
|
||||
expect(CHAIN_FIELDS).toEqual([
|
||||
"id",
|
||||
"pluginInvocationId",
|
||||
"tenantId",
|
||||
"pluginId",
|
||||
"capability",
|
||||
"payloadHash",
|
||||
"outcome",
|
||||
"errorCode",
|
||||
"durationMs",
|
||||
"timestamp",
|
||||
]);
|
||||
});
|
||||
|
||||
test("differentiates null vs empty string", () => {
|
||||
const withNull = canonicalisePluginCapabilityUseRow(row({ errorCode: null }));
|
||||
const withEmpty = canonicalisePluginCapabilityUseRow(row({ errorCode: "" }));
|
||||
expect(withNull).not.toBe(withEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRowHash", () => {
|
||||
test("returns a 64-char hex string", () => {
|
||||
const h = computeRowHash(null, row());
|
||||
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("changes when prevHash changes", () => {
|
||||
const a = computeRowHash(null, row());
|
||||
const b = computeRowHash("abc", row());
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("changes when any field changes", () => {
|
||||
const base = computeRowHash(null, row());
|
||||
expect(computeRowHash(null, row({ pluginId: "other" }))).not.toBe(base);
|
||||
expect(computeRowHash(null, row({ outcome: "error" }))).not.toBe(base);
|
||||
expect(computeRowHash(null, row({ durationMs: 999 }))).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampPluginCapabilityUseChain", () => {
|
||||
test("stamps an empty batch as a no-op", () => {
|
||||
const out = stampPluginCapabilityUseChain({ seedPrevHash: null, rows: [] });
|
||||
expect(out.stamped).toEqual([]);
|
||||
expect(out.chainHead).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves the seed prev hash for a non-empty batch", () => {
|
||||
const out = stampPluginCapabilityUseChain({
|
||||
seedPrevHash: "seed",
|
||||
rows: [row({ id: "r1" })],
|
||||
});
|
||||
expect(out.stamped[0]?.prevHash).toBe("seed");
|
||||
});
|
||||
|
||||
test("threads the rowHash forward through the batch", () => {
|
||||
const out = stampPluginCapabilityUseChain({
|
||||
seedPrevHash: null,
|
||||
rows: [row({ id: "r1" }), row({ id: "r2" }), row({ id: "r3" })],
|
||||
});
|
||||
expect(out.stamped[0]!.prevHash).toBeNull();
|
||||
expect(out.stamped[1]!.prevHash).toBe(out.stamped[0]!.rowHash);
|
||||
expect(out.stamped[2]!.prevHash).toBe(out.stamped[1]!.rowHash);
|
||||
expect(out.chainHead).toBe(out.stamped[2]!.rowHash);
|
||||
});
|
||||
|
||||
test("produces deterministic output for the same input", () => {
|
||||
const a = stampPluginCapabilityUseChain({
|
||||
seedPrevHash: "x",
|
||||
rows: [row({ id: "r1" }), row({ id: "r2" })],
|
||||
});
|
||||
const b = stampPluginCapabilityUseChain({
|
||||
seedPrevHash: "x",
|
||||
rows: [row({ id: "r1" }), row({ id: "r2" })],
|
||||
});
|
||||
expect(a.stamped.map((r) => r.rowHash)).toEqual(b.stamped.map((r) => r.rowHash));
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPluginCapabilityUseChain", () => {
|
||||
function stamped(seed: string | null, rs: PluginCapabilityUseChainRow[]): StampedRow[] {
|
||||
return [...stampPluginCapabilityUseChain({ seedPrevHash: seed, rows: rs }).stamped];
|
||||
}
|
||||
|
||||
test("returns ok for an empty input", () => {
|
||||
expect(verifyPluginCapabilityUseChain([])).toEqual({ ok: true, rowsVerified: 0 });
|
||||
});
|
||||
|
||||
test("returns ok for a freshly-stamped chain", () => {
|
||||
const chain = stamped(null, [row({ id: "r1" }), row({ id: "r2" }), row({ id: "r3" })]);
|
||||
const result = verifyPluginCapabilityUseChain(chain);
|
||||
expect(result).toEqual({ ok: true, rowsVerified: 3 });
|
||||
});
|
||||
|
||||
test("detects a tampered field via row_hash_mismatch", () => {
|
||||
const chain = stamped(null, [row({ id: "r1" }), row({ id: "r2" })]);
|
||||
// Tamper with the second row's outcome — its stored rowHash no
|
||||
// longer matches the recomputed one.
|
||||
const tampered = [chain[0]!, { ...chain[1]!, outcome: "error" }];
|
||||
const result = verifyPluginCapabilityUseChain(tampered);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("row_hash_mismatch");
|
||||
expect(result.brokenRowIndex).toBe(1);
|
||||
expect(result.brokenRowId).toBe("r2");
|
||||
}
|
||||
});
|
||||
|
||||
test("detects a deletion via prev_mismatch on the next row", () => {
|
||||
const chain = stamped(null, [row({ id: "r1" }), row({ id: "r2" }), row({ id: "r3" })]);
|
||||
// Delete the middle row — r3's prevHash references r2's rowHash but
|
||||
// r2 is gone, so the next-row check against the running tail fails.
|
||||
const missingR2 = [chain[0]!, chain[2]!];
|
||||
const result = verifyPluginCapabilityUseChain(missingR2);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("prev_mismatch");
|
||||
expect(result.brokenRowId).toBe("r3");
|
||||
}
|
||||
});
|
||||
|
||||
test("skips pre-F-003 rows (null hashes) without flagging them as breaks", () => {
|
||||
const preF003 = row({ id: "pre1" }); // no prevHash / rowHash → pre-chain
|
||||
const chain = stamped(null, [row({ id: "post1" }), row({ id: "post2" })]);
|
||||
const mixed = [
|
||||
preF003 as PluginCapabilityUseChainRow & { prevHash?: null; rowHash?: null },
|
||||
...chain,
|
||||
];
|
||||
const result = verifyPluginCapabilityUseChain(mixed);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// pre-F-003 row is skipped; post-chain rows count.
|
||||
expect(result.rowsVerified).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("flags a missing rowHash on a row whose prevHash exists", () => {
|
||||
const chain = stamped(null, [row({ id: "r1" })]);
|
||||
// Build a "broken" row where prevHash is set but rowHash is wrong.
|
||||
const broken: StampedRow & { rowHash: string } = {
|
||||
...chain[0]!,
|
||||
rowHash: "deadbeef".repeat(8), // wrong hash
|
||||
};
|
||||
const result = verifyPluginCapabilityUseChain([broken]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("row_hash_mismatch");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createPluginCapabilityUseRecorder,
|
||||
hashCapabilityPayload,
|
||||
PluginCapabilityUseAuditTruncatedError,
|
||||
type PluginCapabilityUseBatch,
|
||||
type PluginCapabilityUseSink,
|
||||
} from "./audit.js";
|
||||
|
||||
function makeSink(): {
|
||||
sink: PluginCapabilityUseSink;
|
||||
batches: PluginCapabilityUseBatch[];
|
||||
} {
|
||||
const batches: PluginCapabilityUseBatch[] = [];
|
||||
return {
|
||||
batches,
|
||||
sink: {
|
||||
async emit(batch) {
|
||||
batches.push(batch);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createPluginCapabilityUseRecorder", () => {
|
||||
test("buffers records then flushes them as a single batch", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "de.acme.slack",
|
||||
sink,
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success", durationMs: 1 });
|
||||
recorder.record({ capability: "http.outbound", outcome: "success", durationMs: 12 });
|
||||
expect(recorder.snapshot()).toHaveLength(2);
|
||||
|
||||
await recorder.flush();
|
||||
expect(batches).toHaveLength(1);
|
||||
expect(batches[0]?.entries).toHaveLength(2);
|
||||
expect(batches[0]?.pluginId).toBe("de.acme.slack");
|
||||
expect(batches[0]?.tenantId).toBe("t1");
|
||||
expect(recorder.snapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("flush is a no-op when no entries were recorded", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink,
|
||||
});
|
||||
await recorder.flush();
|
||||
expect(batches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("uses the supplied pluginInvocationId for every record + the batch", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
pluginInvocationId: "inv-fixed",
|
||||
sink,
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
await recorder.flush();
|
||||
expect(recorder.pluginInvocationId).toBe("inv-fixed");
|
||||
expect(batches[0]?.pluginInvocationId).toBe("inv-fixed");
|
||||
expect(batches[0]?.entries[0]?.pluginInvocationId).toBe("inv-fixed");
|
||||
});
|
||||
|
||||
test("sink failure does not propagate — logs an error instead", async () => {
|
||||
const errors: Record<string, unknown>[] = [];
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink: {
|
||||
async emit() {
|
||||
throw new Error("db down");
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
error: (record) => {
|
||||
errors.push(record);
|
||||
},
|
||||
},
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
await expect(recorder.flush()).resolves.toBeUndefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.entryCount).toBe(1);
|
||||
});
|
||||
|
||||
test("sink failure propagates when failClosed is enabled", async () => {
|
||||
const errors: Record<string, unknown>[] = [];
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
failClosed: true,
|
||||
sink: {
|
||||
async emit() {
|
||||
throw new Error("outbox down");
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
error: (record) => {
|
||||
errors.push(record);
|
||||
},
|
||||
},
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
await expect(recorder.flush()).rejects.toThrow("outbox down");
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.entryCount).toBe(1);
|
||||
});
|
||||
|
||||
test("drops entries past maxEntries and warns once on flush", async () => {
|
||||
const { sink } = makeSink();
|
||||
const warns: Record<string, unknown>[] = [];
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink,
|
||||
maxEntries: 2,
|
||||
logger: {
|
||||
warn: (record) => {
|
||||
warns.push(record);
|
||||
},
|
||||
},
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
expect(recorder.snapshot()).toHaveLength(2);
|
||||
await recorder.flush();
|
||||
expect(warns).toHaveLength(1);
|
||||
expect(warns[0]?.droppedCount).toBe(2);
|
||||
});
|
||||
|
||||
test("fails closed on truncated capability-use audit while preserving retained entries", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const warns: Record<string, unknown>[] = [];
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink,
|
||||
failClosed: true,
|
||||
maxEntries: 2,
|
||||
logger: {
|
||||
warn: (record) => {
|
||||
warns.push(record);
|
||||
},
|
||||
},
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
recorder.record({ capability: "audit", outcome: "success" });
|
||||
recorder.record({ capability: "http.outbound", outcome: "success" });
|
||||
|
||||
await expect(recorder.flush()).rejects.toThrow(PluginCapabilityUseAuditTruncatedError);
|
||||
expect(batches).toHaveLength(1);
|
||||
expect(batches[0]?.entries.map((entry) => entry.capability)).toEqual(["log", "audit"]);
|
||||
expect(warns).toHaveLength(1);
|
||||
expect(warns[0]).toMatchObject({ droppedCount: 1, maxEntries: 2 });
|
||||
});
|
||||
|
||||
test("records error + denied outcomes alongside successes", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink,
|
||||
});
|
||||
recorder.record({ capability: "log", outcome: "success" });
|
||||
recorder.record({
|
||||
capability: "http.outbound",
|
||||
outcome: "denied",
|
||||
errorCode: "not_declared_in_manifest",
|
||||
});
|
||||
recorder.record({
|
||||
capability: "http.outbound",
|
||||
outcome: "error",
|
||||
errorCode: "host_fn_threw",
|
||||
durationMs: 7,
|
||||
});
|
||||
await recorder.flush();
|
||||
const outcomes = batches[0]?.entries.map((e) => e.outcome);
|
||||
expect(outcomes).toEqual(["success", "denied", "error"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashCapabilityPayload", () => {
|
||||
test("produces a deterministic SHA-256 hex digest", () => {
|
||||
const a = hashCapabilityPayload("GET https://example.com");
|
||||
const b = hashCapabilityPayload("GET https://example.com");
|
||||
const c = hashCapabilityPayload("POST https://example.com");
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toEqual(c);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration with capability gateway hook", () => {
|
||||
test("records each invocation when wired as a gateway-onCall hook", async () => {
|
||||
const { sink, batches } = makeSink();
|
||||
const recorder = createPluginCapabilityUseRecorder({
|
||||
tenantId: "t1",
|
||||
pluginId: "p1",
|
||||
sink,
|
||||
});
|
||||
const onCall = vi.fn((info: { capability: string; outcome: "success" | "denied" | "error"; durationMs?: number; errorCode?: string }) => {
|
||||
recorder.record(info);
|
||||
});
|
||||
onCall({ capability: "log", outcome: "success", durationMs: 1 });
|
||||
onCall({ capability: "audit", outcome: "success", durationMs: 1 });
|
||||
await recorder.flush();
|
||||
expect(onCall).toHaveBeenCalledTimes(2);
|
||||
expect(batches[0]?.entries.map((e) => e.capability)).toEqual(["log", "audit"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Plugin capability-use audit recorder — Phase 06 #23.
|
||||
*
|
||||
* Buffers one entry per host-fn call inside a plugin invocation, then
|
||||
* emits them as a single `plugin.capability-use.batch` outbox event at
|
||||
* sandbox close. The worker handler (apps/worker/src/jobs/plugin-
|
||||
* capability-use.ts) is what eventually writes the rows into the
|
||||
* `PluginCapabilityUse` table — keeping the plugin hot-path off the DB.
|
||||
*
|
||||
* Body content is NEVER stored. Callers pass an optional `payloadHash`
|
||||
* (SHA-256 of a stable arg-shape descriptor) so forensics can match a
|
||||
* suspected payload without retaining tenant data. DSGVO-minimisation
|
||||
* applies to every field exposed here.
|
||||
*
|
||||
* Failure mode defaults to soft for local/dev callers. Production
|
||||
* worker bootstraps should set `failClosed: true` so the invocation
|
||||
* fails instead of acknowledging execution without durable capability
|
||||
* telemetry.
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export type PluginCapabilityUseOutcome = "success" | "denied" | "error";
|
||||
|
||||
export interface PluginCapabilityUseEntry {
|
||||
/** Capability name (e.g. "http.outbound", "log"). */
|
||||
readonly capability: string;
|
||||
/** Outcome of the host-fn call. */
|
||||
readonly outcome: PluginCapabilityUseOutcome;
|
||||
/** Optional SHA-256 hex of a stable arg-shape descriptor. */
|
||||
readonly payloadHash?: string;
|
||||
/** Optional short error-code (e.g. "not_declared_in_manifest"). */
|
||||
readonly errorCode?: string;
|
||||
/** Optional duration in milliseconds. */
|
||||
readonly durationMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseRecord extends PluginCapabilityUseEntry {
|
||||
/** Per-call uuid — primary key in the PluginCapabilityUse table. */
|
||||
readonly id: string;
|
||||
/** Sandbox-generated invocation id (groups every host-fn call of one run). */
|
||||
readonly pluginInvocationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
/** ISO-8601 string — the worker parses this back to a Date. */
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseBatch {
|
||||
readonly pluginInvocationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
readonly entries: ReadonlyArray<PluginCapabilityUseRecord>;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseSink {
|
||||
/** Persist a batch. Resolves on success; rejects on transient failure. */
|
||||
emit(batch: PluginCapabilityUseBatch): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseLogger {
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
error?: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseRecorderOptions {
|
||||
readonly tenantId: string;
|
||||
readonly pluginId: string;
|
||||
/**
|
||||
* Optional pre-assigned invocation id. Defaults to a fresh uuid
|
||||
* when omitted (lets the sandbox use the same id as elsewhere).
|
||||
*/
|
||||
readonly pluginInvocationId?: string;
|
||||
readonly sink: PluginCapabilityUseSink;
|
||||
readonly logger?: PluginCapabilityUseLogger;
|
||||
/**
|
||||
* Propagate sink failures after logging them. Production plugin workers
|
||||
* should enable this so capability-use audit loss fails closed.
|
||||
*/
|
||||
readonly failClosed?: boolean;
|
||||
/**
|
||||
* Hard cap on retained entries per invocation. Prevents runaway
|
||||
* plugins from exhausting heap by spamming capability calls.
|
||||
* Defaults to 1024.
|
||||
*/
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
|
||||
export interface PluginCapabilityUseRecorder {
|
||||
readonly pluginInvocationId: string;
|
||||
record(entry: PluginCapabilityUseEntry): void;
|
||||
/** Snapshot of currently buffered records — mostly useful in tests. */
|
||||
snapshot(): ReadonlyArray<PluginCapabilityUseRecord>;
|
||||
/** Emits the batch + clears the buffer. Throws on sink failure when `failClosed` is enabled. */
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 1024;
|
||||
|
||||
export class PluginCapabilityUseAuditTruncatedError extends Error {
|
||||
override readonly name = "PluginCapabilityUseAuditTruncatedError";
|
||||
|
||||
constructor(
|
||||
readonly pluginInvocationId: string,
|
||||
readonly droppedCount: number,
|
||||
readonly maxEntries: number
|
||||
) {
|
||||
super(`Plugin capability-use audit dropped ${droppedCount} entries after maxEntries=${maxEntries}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an invocation-scoped recorder. Callers create one per plugin
|
||||
* invocation, hand the `record()` callback to the capability gateway,
|
||||
* and call `flush()` once the plugin returns (or traps).
|
||||
*/
|
||||
export function createPluginCapabilityUseRecorder(
|
||||
options: PluginCapabilityUseRecorderOptions
|
||||
): PluginCapabilityUseRecorder {
|
||||
const pluginInvocationId = options.pluginInvocationId ?? randomUUID();
|
||||
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
const buffer: PluginCapabilityUseRecord[] = [];
|
||||
let droppedCount = 0;
|
||||
|
||||
return {
|
||||
pluginInvocationId,
|
||||
record(entry) {
|
||||
if (buffer.length >= maxEntries) {
|
||||
droppedCount += 1;
|
||||
return;
|
||||
}
|
||||
buffer.push({
|
||||
...entry,
|
||||
id: randomUUID(),
|
||||
pluginInvocationId,
|
||||
tenantId: options.tenantId,
|
||||
pluginId: options.pluginId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return [...buffer];
|
||||
},
|
||||
async flush() {
|
||||
if (buffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch: PluginCapabilityUseBatch = {
|
||||
pluginInvocationId,
|
||||
tenantId: options.tenantId,
|
||||
pluginId: options.pluginId,
|
||||
entries: [...buffer],
|
||||
};
|
||||
buffer.length = 0;
|
||||
try {
|
||||
await options.sink.emit(batch);
|
||||
buffer.length = 0;
|
||||
} catch (err) {
|
||||
options.logger?.error?.(
|
||||
{
|
||||
err: err instanceof Error ? { name: err.name, message: err.message } : err,
|
||||
pluginInvocationId,
|
||||
tenantId: options.tenantId,
|
||||
pluginId: options.pluginId,
|
||||
entryCount: batch.entries.length,
|
||||
},
|
||||
"plugin capability-use audit flush failed"
|
||||
);
|
||||
if (options.failClosed) {
|
||||
throw err;
|
||||
}
|
||||
buffer.length = 0;
|
||||
}
|
||||
if (droppedCount > 0) {
|
||||
const error = new PluginCapabilityUseAuditTruncatedError(pluginInvocationId, droppedCount, maxEntries);
|
||||
options.logger?.warn?.(
|
||||
{
|
||||
pluginInvocationId,
|
||||
tenantId: options.tenantId,
|
||||
pluginId: options.pluginId,
|
||||
droppedCount,
|
||||
maxEntries,
|
||||
},
|
||||
"plugin capability-use audit dropped entries past max"
|
||||
);
|
||||
droppedCount = 0;
|
||||
if (options.failClosed) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a stable description of host-fn args. Plugin runtimes call this
|
||||
* with the smallest sensible descriptor (e.g. for `http.outbound`
|
||||
* "GET https://hooks.slack.com/services/X") and store the resulting
|
||||
* hex digest in `payloadHash` — never the original body.
|
||||
*/
|
||||
export function hashCapabilityPayload(descriptor: string): string {
|
||||
return createHash("sha256").update(descriptor, "utf8").digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getPluginCapabilityMetadata,
|
||||
getUnavailablePluginCapabilityNames,
|
||||
isPluginCapabilityHostBindingAvailable,
|
||||
PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES,
|
||||
PLUGIN_CAPABILITY_METADATA,
|
||||
} from "./capabilities.js";
|
||||
|
||||
describe("plugin host capability availability", () => {
|
||||
test("marks only reviewed sandbox host bindings as production-available", () => {
|
||||
expect(PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES).toEqual(["log", "audit"]);
|
||||
expect(isPluginCapabilityHostBindingAvailable("log")).toBe(true);
|
||||
expect(isPluginCapabilityHostBindingAvailable("audit")).toBe(true);
|
||||
expect(isPluginCapabilityHostBindingAvailable("http.outbound")).toBe(false);
|
||||
expect(isPluginCapabilityHostBindingAvailable("secret.read")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns sorted unique unavailable capabilities from manifests or names", () => {
|
||||
expect(
|
||||
getUnavailablePluginCapabilityNames([
|
||||
{ name: "log" },
|
||||
{ name: "secret.read" },
|
||||
{ name: "http.outbound" },
|
||||
{ name: "secret.read" },
|
||||
"db.write",
|
||||
"not-a-capability",
|
||||
])
|
||||
).toEqual(["db.write", "http.outbound", "secret.read"]);
|
||||
});
|
||||
|
||||
test("exposes operator-facing capability risk and production availability metadata", () => {
|
||||
expect(Object.keys(PLUGIN_CAPABILITY_METADATA).sort()).toEqual([
|
||||
"audit",
|
||||
"db.read",
|
||||
"db.write",
|
||||
"http.outbound",
|
||||
"log",
|
||||
"secret.read",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"tenant.info",
|
||||
]);
|
||||
expect(getPluginCapabilityMetadata("log")).toMatchObject({ risk: "low", productionAvailable: true });
|
||||
expect(getPluginCapabilityMetadata("audit")).toMatchObject({ risk: "medium", productionAvailable: true });
|
||||
expect(getPluginCapabilityMetadata("http.outbound")).toMatchObject({ risk: "high", productionAvailable: false });
|
||||
expect(getPluginCapabilityMetadata("secret.read")).toMatchObject({ risk: "critical", productionAvailable: false });
|
||||
expect(getPluginCapabilityMetadata("hook:workflow.created")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Single source of truth for the capability names a plugin may declare
|
||||
* in its manifest. The capability gateway (`capability-gateway.ts`)
|
||||
* uses the same enum to know which host-functions to expose; the
|
||||
* manifest schema (`manifest.ts`) uses it to reject unknown values.
|
||||
*
|
||||
* Adding a capability is a deliberate, reviewed operation — keep the
|
||||
* list short. Scope objects (allowed hosts for `http.outbound`,
|
||||
* key-patterns for `secret.read`) live alongside the capability in the
|
||||
* manifest, but their fine-grained schema is intentionally deferred to
|
||||
* the consent-dialog work in #22 to keep this foundation auditable.
|
||||
*/
|
||||
export const PLUGIN_CAPABILITY_NAMES = [
|
||||
"http.outbound",
|
||||
"db.read",
|
||||
"db.write",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"log",
|
||||
"audit",
|
||||
"tenant.info",
|
||||
"secret.read",
|
||||
] as const;
|
||||
|
||||
export type PluginCapabilityName = (typeof PLUGIN_CAPABILITY_NAMES)[number];
|
||||
|
||||
const CAPABILITY_NAME_SET = new Set<string>(PLUGIN_CAPABILITY_NAMES);
|
||||
|
||||
export const PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES = ["log", "audit"] as const satisfies readonly PluginCapabilityName[];
|
||||
|
||||
const AVAILABLE_HOST_CAPABILITY_SET = new Set<string>(PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES);
|
||||
|
||||
export type PluginCapabilityRisk = "low" | "medium" | "high" | "critical";
|
||||
|
||||
export type PluginCapabilityMetadata = {
|
||||
readonly name: PluginCapabilityName;
|
||||
readonly risk: PluginCapabilityRisk;
|
||||
readonly productionAvailable: boolean;
|
||||
};
|
||||
|
||||
export const PLUGIN_CAPABILITY_METADATA = {
|
||||
"http.outbound": { name: "http.outbound", risk: "high", productionAvailable: false },
|
||||
"db.read": { name: "db.read", risk: "high", productionAvailable: false },
|
||||
"db.write": { name: "db.write", risk: "critical", productionAvailable: false },
|
||||
"storage.read": { name: "storage.read", risk: "high", productionAvailable: false },
|
||||
"storage.write": { name: "storage.write", risk: "critical", productionAvailable: false },
|
||||
log: { name: "log", risk: "low", productionAvailable: true },
|
||||
audit: { name: "audit", risk: "medium", productionAvailable: true },
|
||||
"tenant.info": { name: "tenant.info", risk: "medium", productionAvailable: false },
|
||||
"secret.read": { name: "secret.read", risk: "critical", productionAvailable: false },
|
||||
} as const satisfies Record<PluginCapabilityName, PluginCapabilityMetadata>;
|
||||
|
||||
export function isPluginCapabilityName(value: unknown): value is PluginCapabilityName {
|
||||
return typeof value === "string" && CAPABILITY_NAME_SET.has(value);
|
||||
}
|
||||
|
||||
export function getPluginCapabilityMetadata(value: unknown): PluginCapabilityMetadata | null {
|
||||
return isPluginCapabilityName(value) ? PLUGIN_CAPABILITY_METADATA[value] : null;
|
||||
}
|
||||
|
||||
export function isPluginCapabilityHostBindingAvailable(value: unknown): value is (typeof PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES)[number] {
|
||||
return typeof value === "string" && AVAILABLE_HOST_CAPABILITY_SET.has(value);
|
||||
}
|
||||
|
||||
export function getUnavailablePluginCapabilityNames(
|
||||
capabilities: ReadonlyArray<PluginCapabilityName | string | { name: PluginCapabilityName | string }>
|
||||
): PluginCapabilityName[] {
|
||||
const unavailable = new Set<PluginCapabilityName>();
|
||||
for (const capability of capabilities) {
|
||||
const name = typeof capability === "string" ? capability : capability.name;
|
||||
if (isPluginCapabilityName(name) && !isPluginCapabilityHostBindingAvailable(name)) {
|
||||
unavailable.add(name);
|
||||
}
|
||||
}
|
||||
return [...unavailable].sort();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CapabilityDeniedError, createCapabilityGateway } from "./capability-gateway.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
const DEFERRED_HOST_BINDINGS = [
|
||||
"http.outbound",
|
||||
"secret.read",
|
||||
"db.read",
|
||||
"db.write",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"tenant.info",
|
||||
] as const;
|
||||
|
||||
function fixture(capabilities: PluginManifest["capabilities"] = []): PluginManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities,
|
||||
hooks: [{ name: "x", export: "x" }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCapabilityGateway", () => {
|
||||
test("invokes a host-fn whose capability is declared", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: {
|
||||
log: (msg: unknown) => `logged:${String(msg)}`,
|
||||
},
|
||||
});
|
||||
const result = await gateway.invoke<string>("log", "hi");
|
||||
expect(result).toBe("logged:hi");
|
||||
});
|
||||
|
||||
test("denies undeclared capabilities with not_declared_in_manifest", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: {
|
||||
"http.outbound": async () => "should not run",
|
||||
},
|
||||
});
|
||||
await expect(gateway.invoke("http.outbound")).rejects.toThrow(CapabilityDeniedError);
|
||||
await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({
|
||||
reason: "not_declared_in_manifest",
|
||||
});
|
||||
});
|
||||
|
||||
test("denies declared-but-unimplemented capabilities", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "secret.read" }]),
|
||||
hostFns: {},
|
||||
});
|
||||
await expect(gateway.invoke("secret.read")).rejects.toMatchObject({
|
||||
reason: "no_host_implementation",
|
||||
});
|
||||
});
|
||||
|
||||
test.each(DEFERRED_HOST_BINDINGS)("fails closed for deferred %s bindings with no_host_implementation", async (capability) => {
|
||||
const hostFn = vi.fn(async () => "should not run");
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: capability }]),
|
||||
approvedGrants: [{ capabilityName: capability, scopeJson: null }],
|
||||
hostFns: {
|
||||
log: () => "ok",
|
||||
audit: () => "ok",
|
||||
[capability]: hostFn,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(gateway.invoke(capability)).rejects.toMatchObject({
|
||||
reason: "no_host_implementation",
|
||||
});
|
||||
expect(hostFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("denies manifest-declared capabilities without tenant-approved grants", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }, { name: "audit" }]),
|
||||
approvedGrants: [{ capabilityName: "log", scopeJson: null }],
|
||||
hostFns: {
|
||||
log: () => "ok",
|
||||
audit: () => "should not run",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("log")).resolves.toBe("ok");
|
||||
await expect(gateway.invoke("audit")).rejects.toMatchObject({
|
||||
reason: "not_granted_for_tenant",
|
||||
});
|
||||
});
|
||||
|
||||
test("denies tenant grants whose scope exceeds the manifest scope", async () => {
|
||||
const hostFn = vi.fn(async () => "should not run");
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]),
|
||||
approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }],
|
||||
hostFns: {
|
||||
"http.outbound": hostFn,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("http.outbound", "https://api.example.com")).rejects.toMatchObject({
|
||||
reason: "not_granted_for_tenant",
|
||||
});
|
||||
expect(hostFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("charges fuel per call (defaults to 100)", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: { log: () => "ok" },
|
||||
});
|
||||
expect(gateway.fuelCharged()).toBe(0);
|
||||
await gateway.invoke("log");
|
||||
expect(gateway.fuelCharged()).toBe(100);
|
||||
await gateway.invoke("log");
|
||||
expect(gateway.fuelCharged()).toBe(200);
|
||||
});
|
||||
|
||||
test("respects fuelCostPerCall override and resetFuel", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: { log: () => "ok" },
|
||||
fuelCostPerCall: 25,
|
||||
});
|
||||
await gateway.invoke("log");
|
||||
await gateway.invoke("log");
|
||||
expect(gateway.fuelCharged()).toBe(50);
|
||||
gateway.resetFuel();
|
||||
expect(gateway.fuelCharged()).toBe(0);
|
||||
});
|
||||
|
||||
test("calls onDenied with the deny reason for both deny paths", async () => {
|
||||
const denials: Array<{ capability: string; reason: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "secret.read" }]),
|
||||
hostFns: {},
|
||||
onDenied: (info) => denials.push(info),
|
||||
});
|
||||
await expect(gateway.invoke("http.outbound")).rejects.toThrow();
|
||||
await expect(gateway.invoke("secret.read")).rejects.toThrow();
|
||||
expect(denials.map((d) => d.reason)).toEqual([
|
||||
"not_declared_in_manifest",
|
||||
"no_host_implementation",
|
||||
]);
|
||||
});
|
||||
|
||||
test("records onCall outcomes for success, denial, and host-fn errors", async () => {
|
||||
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }, { name: "audit" }]),
|
||||
hostFns: {
|
||||
log: () => "ok",
|
||||
audit: () => {
|
||||
throw new TypeError("audit sink unavailable");
|
||||
},
|
||||
},
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("log")).resolves.toBe("ok");
|
||||
await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({
|
||||
reason: "not_declared_in_manifest",
|
||||
});
|
||||
await expect(gateway.invoke("db.read")).rejects.toMatchObject({
|
||||
reason: "not_declared_in_manifest",
|
||||
});
|
||||
await expect(gateway.invoke("audit")).rejects.toThrow("audit sink unavailable");
|
||||
|
||||
expect(calls).toMatchObject([
|
||||
{ capability: "log", outcome: "success" },
|
||||
{ capability: "http.outbound", outcome: "denied", errorCode: "not_declared_in_manifest" },
|
||||
{ capability: "db.read", outcome: "denied", errorCode: "not_declared_in_manifest" },
|
||||
{ capability: "audit", outcome: "error", errorCode: "TypeError" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("records deterministic payload hashes without exposing raw args", async () => {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: {
|
||||
log: () => "ok",
|
||||
},
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
await gateway.invoke("log", "same-value");
|
||||
await gateway.invoke("log", "same-value");
|
||||
await gateway.invoke("log", "other-value");
|
||||
|
||||
expect(calls[0]?.payloadHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(calls[1]?.payloadHash).toBe(calls[0]?.payloadHash);
|
||||
expect(calls[2]?.payloadHash).not.toBe(calls[0]?.payloadHash);
|
||||
expect(JSON.stringify(calls)).not.toContain("same-value");
|
||||
expect(JSON.stringify(calls)).not.toContain("other-value");
|
||||
});
|
||||
|
||||
test("records no_host_implementation through onCall", async () => {
|
||||
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "secret.read" }]),
|
||||
hostFns: {},
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("secret.read")).rejects.toMatchObject({
|
||||
reason: "no_host_implementation",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
capability: "secret.read",
|
||||
outcome: "denied",
|
||||
errorCode: "no_host_implementation",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps future scoped host bindings unavailable before dispatch", async () => {
|
||||
const hostFn = vi.fn(async () => "should not run");
|
||||
const denials: Array<{ capability: string; reason: string }> = [];
|
||||
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]),
|
||||
approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
|
||||
hostFns: {
|
||||
"http.outbound": hostFn,
|
||||
},
|
||||
onDenied: (info) => denials.push(info),
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("http.outbound", { url: "https://evil.example.com/v1" })).rejects.toMatchObject({
|
||||
reason: "no_host_implementation",
|
||||
});
|
||||
expect(hostFn).not.toHaveBeenCalled();
|
||||
expect(denials).toEqual([{ capability: "http.outbound", reason: "no_host_implementation" }]);
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
capability: "http.outbound",
|
||||
outcome: "denied",
|
||||
errorCode: "no_host_implementation",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores onCall recorder failures", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: { log: () => "ok" },
|
||||
onCall: () => {
|
||||
throw new Error("audit sink down");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(gateway.invoke("log")).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
test("propagates async reviewed host-fn results unchanged", async () => {
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "audit" }]),
|
||||
hostFns: {
|
||||
audit: async () => ({ recorded: true }),
|
||||
},
|
||||
});
|
||||
const out = await gateway.invoke<{ recorded: boolean }>("audit");
|
||||
expect(out).toEqual({ recorded: true });
|
||||
});
|
||||
|
||||
test.each(DEFERRED_HOST_BINDINGS)("invokeSync fails closed for deferred %s bindings even when wired", (capability) => {
|
||||
const hostFn = vi.fn(() => "should not run");
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: capability }]),
|
||||
approvedGrants: [{ capabilityName: capability, scopeJson: null }],
|
||||
hostFns: {
|
||||
[capability]: hostFn,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => gateway.invokeSync(capability)).toThrow(CapabilityDeniedError);
|
||||
expect(() => gateway.invokeSync(capability)).toThrow(`capability denied: ${capability} (no_host_implementation)`);
|
||||
expect(hostFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("invokeSync gates synchronous host imports and records calls", () => {
|
||||
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "log" }]),
|
||||
hostFns: {
|
||||
log: (msg: unknown) => `logged:${String(msg)}`,
|
||||
},
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
expect(gateway.invokeSync("log", "hi")).toBe("logged:hi");
|
||||
expect(gateway.fuelCharged()).toBe(100);
|
||||
expect(() => gateway.invokeSync("audit", { action: "x" })).toThrow(CapabilityDeniedError);
|
||||
expect(calls).toMatchObject([
|
||||
{ capability: "log", outcome: "success" },
|
||||
{ capability: "audit", outcome: "denied", errorCode: "not_declared_in_manifest" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("invokeSync rejects async host functions before reporting success", () => {
|
||||
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
|
||||
const gateway = createCapabilityGateway({
|
||||
manifest: fixture([{ name: "audit" }]),
|
||||
hostFns: {
|
||||
audit: async () => "later",
|
||||
},
|
||||
onCall: (info) => calls.push(info),
|
||||
});
|
||||
|
||||
expect(() => gateway.invokeSync("audit")).toThrow("async_host_fn_not_supported");
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
capability: "audit",
|
||||
outcome: "error",
|
||||
errorCode: "Error",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { PluginCapabilityName } from "./capabilities.js";
|
||||
import { isPluginCapabilityHostBindingAvailable } from "./capabilities.js";
|
||||
import { isCapabilityGrantScopeSatisfied, type PluginCapabilityGrant } from "./capability-grants.js";
|
||||
import { isCapabilityCallWithinScope } from "./capability-scope.js";
|
||||
import { hashCapabilityPayload } from "./audit.js";
|
||||
import { manifestDeclaresCapability, type PluginManifest } from "./manifest.js";
|
||||
|
||||
/**
|
||||
* Capability gateway — Phase 06 #21 foundation.
|
||||
*
|
||||
* Wraps a record of host functions so that a plugin can only invoke
|
||||
* the ones it declared in its manifest. Calls to undeclared host
|
||||
* functions throw `CapabilityDeniedError`. The gateway also charges a
|
||||
* configurable fuel cost on every successful host-fn invocation, which
|
||||
* is what the sandbox uses to enforce a CPU-budget-by-proxy on plugins
|
||||
* (since V8 has no public hook for per-instruction metering).
|
||||
*
|
||||
* Defaults are deny-by-default: capabilities not in `PLUGIN_CAPABILITY_NAMES`
|
||||
* (the closed enum from `capabilities.ts`) cannot be registered.
|
||||
*/
|
||||
|
||||
export type CapabilityHostFn = (...args: unknown[]) => unknown | Promise<unknown>;
|
||||
|
||||
export type CapabilityHostFnMap = Partial<Record<PluginCapabilityName, CapabilityHostFn>>;
|
||||
|
||||
export type CapabilityGatewayOptions = {
|
||||
manifest: PluginManifest;
|
||||
hostFns: CapabilityHostFnMap;
|
||||
/**
|
||||
* Tenant-approved grants for this invocation. When provided, the gateway
|
||||
* requires every host-fn call to be both manifest-declared and granted.
|
||||
*/
|
||||
approvedGrants?: ReadonlyArray<PluginCapabilityGrant>;
|
||||
/** Charged on every successful host-fn call. Default: 100. */
|
||||
fuelCostPerCall?: number;
|
||||
/** Called when a denied capability is invoked — for audit logging. */
|
||||
onDenied?: (info: { capability: PluginCapabilityName; reason: CapabilityDenyReason }) => void;
|
||||
/**
|
||||
* Phase 06 #23 — called after every host-fn call (success / error /
|
||||
* denied). Wire to a `PluginCapabilityUseRecorder` to feed the audit
|
||||
* log. Synchronous and best-effort: errors thrown here are swallowed
|
||||
* so audit-sink trouble never aborts a plugin invocation.
|
||||
*/
|
||||
onCall?: (info: CapabilityCallInfo) => void;
|
||||
};
|
||||
|
||||
export type CapabilityCallInfo = {
|
||||
capability: PluginCapabilityName;
|
||||
outcome: "success" | "denied" | "error";
|
||||
durationMs: number;
|
||||
errorCode?: string;
|
||||
payloadHash?: string;
|
||||
};
|
||||
|
||||
export type CapabilityDenyReason =
|
||||
| "not_declared_in_manifest"
|
||||
| "not_granted_for_tenant"
|
||||
| "scope_violation"
|
||||
| "unknown_capability"
|
||||
| "no_host_implementation";
|
||||
|
||||
export class CapabilityDeniedError extends Error {
|
||||
override readonly name = "CapabilityDeniedError";
|
||||
readonly capability: string;
|
||||
readonly reason: CapabilityDenyReason;
|
||||
constructor(capability: string, reason: CapabilityDenyReason) {
|
||||
super(`capability denied: ${capability} (${reason})`);
|
||||
this.capability = capability;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export type CapabilityGateway = {
|
||||
/** Invoke a host-fn through the gateway. Throws on denial. */
|
||||
invoke<T = unknown>(capability: PluginCapabilityName, ...args: unknown[]): Promise<T>;
|
||||
/**
|
||||
* Invoke a synchronous host-fn through the gateway. Intended for WASM
|
||||
* imports that cannot await promises.
|
||||
*/
|
||||
invokeSync<T = unknown>(capability: PluginCapabilityName, ...args: unknown[]): T;
|
||||
/** Cumulative fuel cost charged across all calls so far. */
|
||||
fuelCharged(): number;
|
||||
/** Reset the fuel counter — used between invocations. */
|
||||
resetFuel(): void;
|
||||
};
|
||||
|
||||
const DEFAULT_FUEL_COST = 100;
|
||||
|
||||
/**
|
||||
* Build a gateway. The gateway is invocation-scoped (one per plugin
|
||||
* call); callers `resetFuel()` between calls when reusing the same
|
||||
* sandbox instance.
|
||||
*/
|
||||
export function createCapabilityGateway(options: CapabilityGatewayOptions): CapabilityGateway {
|
||||
const { manifest, hostFns } = options;
|
||||
const fuelCostPerCall = options.fuelCostPerCall ?? DEFAULT_FUEL_COST;
|
||||
const approvedGrantByName = options.approvedGrants ? new Map(options.approvedGrants.map((grant) => [grant.capabilityName, grant])) : null;
|
||||
let fuel = 0;
|
||||
|
||||
function notifyCall(info: CapabilityCallInfo): void {
|
||||
if (!options.onCall) return;
|
||||
try {
|
||||
options.onCall(info);
|
||||
} catch {
|
||||
// Audit sink must never abort a plugin invocation. Drop and
|
||||
// continue — the sink's own logger reports the failure.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
invoke: async <T>(capability: PluginCapabilityName, ...args: unknown[]): Promise<T> => {
|
||||
const startedAt = Date.now();
|
||||
if (!manifestDeclaresCapability(manifest, capability)) {
|
||||
options.onDenied?.({ capability, reason: "not_declared_in_manifest" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "not_declared_in_manifest",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "not_declared_in_manifest");
|
||||
}
|
||||
const approvedGrant = approvedGrantByName ? getCapabilityApprovedGrantForManifestScope(manifest, approvedGrantByName, capability) : null;
|
||||
if (approvedGrantByName && !approvedGrant) {
|
||||
options.onDenied?.({ capability, reason: "not_granted_for_tenant" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "not_granted_for_tenant",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "not_granted_for_tenant");
|
||||
}
|
||||
if (!isPluginCapabilityHostBindingAvailable(capability)) {
|
||||
options.onDenied?.({ capability, reason: "no_host_implementation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "no_host_implementation",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "no_host_implementation");
|
||||
}
|
||||
const fn = hostFns[capability];
|
||||
if (typeof fn !== "function") {
|
||||
options.onDenied?.({ capability, reason: "no_host_implementation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "no_host_implementation",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "no_host_implementation");
|
||||
}
|
||||
if (
|
||||
approvedGrant &&
|
||||
!isCapabilityCallWithinScope({
|
||||
capability,
|
||||
callArgs: args,
|
||||
scope: approvedGrant.scopeJson,
|
||||
})
|
||||
) {
|
||||
options.onDenied?.({ capability, reason: "scope_violation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "scope_violation",
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "scope_violation");
|
||||
}
|
||||
// Always charge fuel before invoking, so a host-fn that throws
|
||||
// still counts against the plugin's budget — matches Wasmtime
|
||||
// semantics where consumed fuel survives a trap.
|
||||
fuel += fuelCostPerCall;
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - startedAt,
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "error",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: err instanceof Error ? err.name : "unknown",
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
invokeSync: <T>(capability: PluginCapabilityName, ...args: unknown[]): T => {
|
||||
const startedAt = Date.now();
|
||||
if (!manifestDeclaresCapability(manifest, capability)) {
|
||||
options.onDenied?.({ capability, reason: "not_declared_in_manifest" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "not_declared_in_manifest",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "not_declared_in_manifest");
|
||||
}
|
||||
const approvedGrant = approvedGrantByName ? getCapabilityApprovedGrantForManifestScope(manifest, approvedGrantByName, capability) : null;
|
||||
if (approvedGrantByName && !approvedGrant) {
|
||||
options.onDenied?.({ capability, reason: "not_granted_for_tenant" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "not_granted_for_tenant",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "not_granted_for_tenant");
|
||||
}
|
||||
if (!isPluginCapabilityHostBindingAvailable(capability)) {
|
||||
options.onDenied?.({ capability, reason: "no_host_implementation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "no_host_implementation",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "no_host_implementation");
|
||||
}
|
||||
const fn = hostFns[capability];
|
||||
if (typeof fn !== "function") {
|
||||
options.onDenied?.({ capability, reason: "no_host_implementation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "no_host_implementation",
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "no_host_implementation");
|
||||
}
|
||||
if (
|
||||
approvedGrant &&
|
||||
!isCapabilityCallWithinScope({
|
||||
capability,
|
||||
callArgs: args,
|
||||
scope: approvedGrant.scopeJson,
|
||||
})
|
||||
) {
|
||||
options.onDenied?.({ capability, reason: "scope_violation" });
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "denied",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: "scope_violation",
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
throw new CapabilityDeniedError(capability, "scope_violation");
|
||||
}
|
||||
fuel += fuelCostPerCall;
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (result && typeof (result as Promise<unknown>).then === "function") {
|
||||
throw new Error("async_host_fn_not_supported");
|
||||
}
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - startedAt,
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
notifyCall({
|
||||
capability,
|
||||
outcome: "error",
|
||||
durationMs: Date.now() - startedAt,
|
||||
errorCode: err instanceof Error ? err.name : "unknown",
|
||||
payloadHash: hashCapabilityPayload(createCapabilityPayloadDescriptor(capability, args)),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
fuelCharged: () => fuel,
|
||||
resetFuel: () => {
|
||||
fuel = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getCapabilityApprovedGrantForManifestScope(
|
||||
manifest: PluginManifest,
|
||||
approvedGrantByName: ReadonlyMap<string, PluginCapabilityGrant>,
|
||||
capability: PluginCapabilityName
|
||||
): PluginCapabilityGrant | null {
|
||||
const grant = approvedGrantByName.get(capability);
|
||||
const declaredCapability = manifest.capabilities.find((candidate) => candidate.name === capability);
|
||||
return grant &&
|
||||
declaredCapability &&
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: declaredCapability.scope ?? null,
|
||||
grantedScopeJson: grant.scopeJson,
|
||||
})
|
||||
? grant
|
||||
: null;
|
||||
}
|
||||
|
||||
function createCapabilityPayloadDescriptor(capability: PluginCapabilityName, args: readonly unknown[]): string {
|
||||
return JSON.stringify({
|
||||
capability,
|
||||
args: args.map((arg) => describeCapabilityArgument(arg)),
|
||||
});
|
||||
}
|
||||
|
||||
function describeCapabilityArgument(arg: unknown): unknown {
|
||||
if (arg === null) return null;
|
||||
if (Array.isArray(arg)) return { type: "array", length: arg.length };
|
||||
if (typeof arg === "object") {
|
||||
return { type: "object", keys: Object.keys(arg).sort() };
|
||||
}
|
||||
return { type: typeof arg, value: arg };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getMissingCapabilityGrantNames,
|
||||
isCapabilityGrantScopeSatisfied,
|
||||
} from "./capability-grants.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
function manifest(capabilities: PluginManifest["capabilities"]): PluginManifest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities,
|
||||
hooks: [{ name: "run", export: "run" }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("capability grants", () => {
|
||||
test("accepts exact or narrower declared scope", () => {
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: { allowedHosts: ["api.example.com", "hooks.example.com"] },
|
||||
grantedScopeJson: { allowedHosts: ["api.example.com"] },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: { keyPattern: "stripe_*", tablePrefix: "plugin_" },
|
||||
grantedScopeJson: { keyPattern: "stripe_*", tablePrefix: "plugin_" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects broadened or malformed scopes", () => {
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: { allowedHosts: ["api.example.com"] },
|
||||
grantedScopeJson: { allowedHosts: ["api.example.com", "internal.example"] },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: null,
|
||||
grantedScopeJson: { tablePrefix: "tenant_" },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: { allowedHosts: ["api.example.com"] },
|
||||
grantedScopeJson: "all",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty host grants for scoped outbound capabilities", () => {
|
||||
expect(
|
||||
isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: { allowedHosts: ["api.example.com"] },
|
||||
grantedScopeJson: { allowedHosts: [] },
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
const missing = getMissingCapabilityGrantNames({
|
||||
manifest: manifest([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]),
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: [] } }],
|
||||
});
|
||||
|
||||
expect(missing).toEqual(["http.outbound"]);
|
||||
});
|
||||
|
||||
test("reports missing grants and stale scopes from a manifest", () => {
|
||||
const missing = getMissingCapabilityGrantNames({
|
||||
manifest: manifest([
|
||||
{ name: "log" },
|
||||
{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } },
|
||||
{ name: "audit" },
|
||||
]),
|
||||
grants: [
|
||||
{ capabilityName: "log", scopeJson: null },
|
||||
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(missing).toEqual(["http.outbound", "audit"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PluginCapabilityName } from "./capabilities.js";
|
||||
import type { PluginCapability, PluginManifest } from "./manifest.js";
|
||||
|
||||
export type PluginCapabilityGrant = {
|
||||
capabilityName: string;
|
||||
scopeJson: unknown;
|
||||
};
|
||||
|
||||
type CapabilityScope = NonNullable<PluginCapability["scope"]>;
|
||||
|
||||
const CAPABILITY_SCOPE_KEYS = new Set(["allowedHosts", "keyPattern", "tablePrefix"]);
|
||||
|
||||
function normalizeCapabilityScope(scope: unknown): Partial<CapabilityScope> | null {
|
||||
if (scope === null || scope === undefined) return null;
|
||||
if (typeof scope !== "object" || Array.isArray(scope)) return null;
|
||||
return scope as Partial<CapabilityScope>;
|
||||
}
|
||||
|
||||
function hasScopeValue(scope: Partial<CapabilityScope> | null): boolean {
|
||||
if (!scope) return false;
|
||||
return Boolean((Array.isArray(scope.allowedHosts) && scope.allowedHosts.length > 0) || scope.keyPattern || scope.tablePrefix);
|
||||
}
|
||||
|
||||
export function isCapabilityGrantScopeSatisfied(args: {
|
||||
declaredScope: PluginCapability["scope"] | null | undefined;
|
||||
grantedScopeJson: unknown;
|
||||
}): boolean {
|
||||
const declaredScope = normalizeCapabilityScope(args.declaredScope);
|
||||
const grantedScope = normalizeCapabilityScope(args.grantedScopeJson);
|
||||
if (args.grantedScopeJson !== null && args.grantedScopeJson !== undefined && !grantedScope) return false;
|
||||
|
||||
const grantedKeys = grantedScope ? Object.keys(grantedScope) : [];
|
||||
if (grantedKeys.some((key) => !CAPABILITY_SCOPE_KEYS.has(key))) return false;
|
||||
if (!hasScopeValue(declaredScope)) {
|
||||
return grantedKeys.length === 0;
|
||||
}
|
||||
if (!grantedScope) return false;
|
||||
|
||||
if (declaredScope?.allowedHosts?.length) {
|
||||
if (!Array.isArray(grantedScope.allowedHosts) || grantedScope.allowedHosts.length === 0) return false;
|
||||
const declaredHosts = new Set(declaredScope.allowedHosts);
|
||||
if (grantedScope.allowedHosts.some((host) => !declaredHosts.has(host))) return false;
|
||||
} else if (grantedScope.allowedHosts?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (declaredScope?.keyPattern) {
|
||||
if (grantedScope.keyPattern !== declaredScope.keyPattern) return false;
|
||||
} else if (grantedScope.keyPattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (declaredScope?.tablePrefix) {
|
||||
if (grantedScope.tablePrefix !== declaredScope.tablePrefix) return false;
|
||||
} else if (grantedScope.tablePrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMissingCapabilityGrantNames(args: {
|
||||
manifest: PluginManifest;
|
||||
grants: ReadonlyArray<PluginCapabilityGrant>;
|
||||
}): PluginCapabilityName[] {
|
||||
const grantByCapability = new Map(args.grants.map((grant) => [grant.capabilityName, grant]));
|
||||
return (args.manifest.capabilities ?? [])
|
||||
.filter((capability) => {
|
||||
const grant = grantByCapability.get(capability.name);
|
||||
return (
|
||||
!grant ||
|
||||
!isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: capability.scope ?? null,
|
||||
grantedScopeJson: grant.scopeJson,
|
||||
})
|
||||
);
|
||||
})
|
||||
.map((capability) => capability.name);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isCapabilityCallWithinScope } from "./capability-scope.js";
|
||||
|
||||
describe("capability call scope", () => {
|
||||
test("allows exact HTTPS outbound hosts only", () => {
|
||||
const scope = { allowedHosts: ["api.example.com"] };
|
||||
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://api.example.com/v1" }], scope })).toBe(true);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://evil.example.com/v1" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "http://api.example.com/v1" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://api.example.com.evil.test/v1" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://api.example.com:8443/v1" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "not a url" }], scope })).toBe(false);
|
||||
});
|
||||
|
||||
test("requires structured outbound request arguments and a scoped host grant", () => {
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: ["https://api.example.com/v1"], scope: { allowedHosts: ["api.example.com"] } })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://api.example.com/v1" }], scope: null })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "http.outbound", callArgs: [{ url: "https://api.example.com/v1" }, { retry: true }], scope: { allowedHosts: ["api.example.com"] } })).toBe(false);
|
||||
});
|
||||
|
||||
test("matches secret keys with full-string glob patterns", () => {
|
||||
const scope = { keyPattern: "stripe_*" };
|
||||
|
||||
expect(isCapabilityCallWithinScope({ capability: "secret.read", callArgs: [{ key: "stripe_live_key" }], scope })).toBe(true);
|
||||
expect(isCapabilityCallWithinScope({ capability: "secret.read", callArgs: [{ key: "tenant/stripe_live_key" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "secret.read", callArgs: [{ key: "github_token" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "secret.read", callArgs: [{ key: "stripe_live_key" }], scope: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("limits database calls to tenant-safe table prefixes and rejects raw query shapes", () => {
|
||||
const scope = { tablePrefix: "plugin_" };
|
||||
|
||||
expect(isCapabilityCallWithinScope({ capability: "db.read", callArgs: [{ table: "plugin_orders" }], scope })).toBe(true);
|
||||
expect(isCapabilityCallWithinScope({ capability: "db.write", callArgs: [{ table: "plugin_orders" }], scope })).toBe(true);
|
||||
expect(isCapabilityCallWithinScope({ capability: "db.read", callArgs: [{ table: "tenant_users" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "db.write", callArgs: [{ table: "plugin_orders", sql: "delete from tenant_users" }], scope })).toBe(false);
|
||||
expect(isCapabilityCallWithinScope({ capability: "db.read", callArgs: [{ table: "plugin_orders" }], scope: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("does not scope low-risk host bindings that carry their own structured contracts", () => {
|
||||
expect(isCapabilityCallWithinScope({ capability: "log", callArgs: ["hello"], scope: null })).toBe(true);
|
||||
expect(isCapabilityCallWithinScope({ capability: "audit", callArgs: [{ action: "plugin.test" }], scope: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PluginCapabilityName } from "./capabilities.js";
|
||||
|
||||
export function isCapabilityCallWithinScope(args: {
|
||||
capability: PluginCapabilityName;
|
||||
callArgs: readonly unknown[];
|
||||
scope: unknown;
|
||||
}): boolean {
|
||||
switch (args.capability) {
|
||||
case "http.outbound":
|
||||
return isHttpOutboundCallWithinScope(args.callArgs, args.scope);
|
||||
case "secret.read":
|
||||
return isSecretReadCallWithinScope(args.callArgs, args.scope);
|
||||
case "db.read":
|
||||
case "db.write":
|
||||
return isDatabaseCallWithinScope(args.callArgs, args.scope);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpOutboundCallWithinScope(callArgs: readonly unknown[], scope: unknown): boolean {
|
||||
const request = readObject(callArgs[0]);
|
||||
const scopeObject = readObject(scope);
|
||||
const allowedHosts = normalizeAllowedHosts(scopeObject?.allowedHosts);
|
||||
if (!request || allowedHosts.size === 0 || callArgs.length !== 1) return false;
|
||||
const url = typeof request.url === "string" ? parseScopedUrl(request.url) : null;
|
||||
if (!url) return false;
|
||||
return allowedHosts.has(url.hostname.toLowerCase());
|
||||
}
|
||||
|
||||
function isSecretReadCallWithinScope(callArgs: readonly unknown[], scope: unknown): boolean {
|
||||
const request = readObject(callArgs[0]);
|
||||
if (!request || callArgs.length !== 1 || typeof request.key !== "string") return false;
|
||||
const scopeObject = readObject(scope);
|
||||
if (typeof scopeObject?.keyPattern !== "string") return false;
|
||||
return globMatches(scopeObject.keyPattern, request.key);
|
||||
}
|
||||
|
||||
function isDatabaseCallWithinScope(callArgs: readonly unknown[], scope: unknown): boolean {
|
||||
const request = readObject(callArgs[0]);
|
||||
if (!request || callArgs.length !== 1 || typeof request.table !== "string") return false;
|
||||
const scopeObject = readObject(scope);
|
||||
if (typeof scopeObject?.tablePrefix !== "string") return false;
|
||||
if ("sql" in request || "query" in request || "raw" in request) return false;
|
||||
return request.table.startsWith(scopeObject.tablePrefix);
|
||||
}
|
||||
|
||||
function readObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeAllowedHosts(hosts: unknown): ReadonlySet<string> {
|
||||
if (!Array.isArray(hosts)) return new Set();
|
||||
return new Set(hosts.filter((host): host is string => typeof host === "string").map((host) => host.toLowerCase()));
|
||||
}
|
||||
|
||||
function parseScopedUrl(value: string): URL | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:") return null;
|
||||
if (url.username || url.password || url.port) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function globMatches(pattern: string, value: string): boolean {
|
||||
const source = pattern
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&"))
|
||||
.join(".*");
|
||||
return new RegExp(`^${source}$`, "u").test(value);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createCrashTracker, type CrashTrackerStore } from "./crash-tracker.js";
|
||||
|
||||
function makeMemoryStore(): CrashTrackerStore {
|
||||
const map = new Map<string, number>();
|
||||
return {
|
||||
async incr(k, by = 1) {
|
||||
const next = (map.get(k) ?? 0) + by;
|
||||
map.set(k, next);
|
||||
return next;
|
||||
},
|
||||
async expire() {
|
||||
return true;
|
||||
},
|
||||
async get(k) {
|
||||
const v = map.get(k);
|
||||
return v === undefined ? null : String(v);
|
||||
},
|
||||
async set(k, v) {
|
||||
map.set(k, Number(v));
|
||||
return true;
|
||||
},
|
||||
async del(k) {
|
||||
return map.delete(k) ? 1 : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCrashTracker", () => {
|
||||
test("records failures and reports running count", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const disablePlugin = vi.fn();
|
||||
const tracker = createCrashTracker({ store, disablePlugin, options: { threshold: 5 } });
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const r = await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
expect(r.autoDisabled).toBe(false);
|
||||
}
|
||||
expect(await tracker.getFailureCount({ pluginId: "p", tenantId: "t" })).toBe(4);
|
||||
expect(disablePlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("auto-disables when threshold is crossed", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const disablePlugin = vi.fn().mockResolvedValue(undefined);
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const tracker = createCrashTracker({ store, disablePlugin, notify, options: { threshold: 3 } });
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
const result = await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
expect(result.autoDisabled).toBe(true);
|
||||
expect(disablePlugin).toHaveBeenCalledOnce();
|
||||
expect(notify).toHaveBeenCalledOnce();
|
||||
// Counter should reset so a re-enable doesn't trip again.
|
||||
expect(await tracker.getFailureCount({ pluginId: "p", tenantId: "t" })).toBe(0);
|
||||
});
|
||||
|
||||
test("passes failure correlation through auto-disable hooks", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const disablePlugin = vi.fn().mockResolvedValue(undefined);
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const tracker = createCrashTracker({ store, disablePlugin, notify, options: { threshold: 1 } });
|
||||
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap", correlationId: "inv-1" });
|
||||
|
||||
expect(disablePlugin).toHaveBeenCalledWith({
|
||||
pluginId: "p",
|
||||
tenantId: "t",
|
||||
failureCount: 1,
|
||||
reason: "trap",
|
||||
correlationId: "inv-1",
|
||||
});
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
pluginId: "p",
|
||||
tenantId: "t",
|
||||
failureCount: 1,
|
||||
reason: "trap",
|
||||
correlationId: "inv-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses deterministic fallback correlation when invocation id is missing", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const disablePlugin = vi.fn().mockResolvedValue(undefined);
|
||||
const tracker = createCrashTracker({ store, disablePlugin, options: { threshold: 2 } });
|
||||
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
|
||||
expect(disablePlugin).toHaveBeenCalledWith({
|
||||
pluginId: "p",
|
||||
tenantId: "t",
|
||||
failureCount: 2,
|
||||
reason: "trap",
|
||||
correlationId: "missing-invocation:2",
|
||||
});
|
||||
});
|
||||
|
||||
test("recordSuccess decrements failure count toward zero", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const tracker = createCrashTracker({
|
||||
store,
|
||||
disablePlugin: vi.fn(),
|
||||
options: { threshold: 5 },
|
||||
});
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
expect((await tracker.recordSuccess({ pluginId: "p", tenantId: "t" })).failureCount).toBe(1);
|
||||
expect((await tracker.recordSuccess({ pluginId: "p", tenantId: "t" })).failureCount).toBe(0);
|
||||
// Already-zero counter stays at zero.
|
||||
expect((await tracker.recordSuccess({ pluginId: "p", tenantId: "t" })).failureCount).toBe(0);
|
||||
});
|
||||
|
||||
test("keeps the crash counter when auto-disable fails", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const disablePlugin = vi.fn().mockRejectedValue(new Error("db down"));
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const tracker = createCrashTracker({ store, disablePlugin, notify, options: { threshold: 1 } });
|
||||
const result = await tracker.recordFailure({ pluginId: "p", tenantId: "t", reason: "trap" });
|
||||
expect(result).toEqual({ failureCount: 1, autoDisabled: false });
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(await tracker.getFailureCount({ pluginId: "p", tenantId: "t" })).toBe(1);
|
||||
});
|
||||
|
||||
test("getFailureCount returns 0 for unknown keys", async () => {
|
||||
const store = makeMemoryStore();
|
||||
const tracker = createCrashTracker({ store, disablePlugin: vi.fn() });
|
||||
expect(await tracker.getFailureCount({ pluginId: "x", tenantId: "y" })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Phase 06 #22 — Plugin crash tracker with auto-disable.
|
||||
*
|
||||
* Backed by SharedStore counters: each failure for
|
||||
* `(pluginId, tenantId)` increments a key that expires after a
|
||||
* sliding-window TTL. When the count crosses the configured threshold
|
||||
* the tracker:
|
||||
* 1. flips the Plugin row's `enabled` flag to `false` via the
|
||||
* injected disable seam,
|
||||
* 2. emits an audit-event-shaped notification through the injected
|
||||
* `notify` seam,
|
||||
* 3. resets the counter so a re-enable does not immediately
|
||||
* auto-disable again.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Successful invocations call `recordSuccess`, which decrements
|
||||
* (floored at 0) so flaky-but-mostly-OK plugins don't accumulate
|
||||
* stale failures forever.
|
||||
* - The tracker is intentionally seam-driven: it accepts a Redis-
|
||||
* shaped `incr / expire / get / set / del` surface so the worker
|
||||
* side can use the real shared-store adapter and tests can pass
|
||||
* an in-memory fake.
|
||||
* - Default window / threshold come from environment variables
|
||||
* PLUGIN_AUTO_DISABLE_THRESHOLD (default 10) and
|
||||
* PLUGIN_AUTO_DISABLE_WINDOW_MS (default 600000).
|
||||
*/
|
||||
|
||||
export type CrashTrackerStore = {
|
||||
incr(key: string, by?: number): Promise<number>;
|
||||
/** Set TTL on an existing key. Returns true when the key existed. */
|
||||
expire(key: string, ttlSeconds: number): Promise<boolean>;
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, opts?: { ttlSeconds?: number }): Promise<boolean>;
|
||||
del(key: string): Promise<number>;
|
||||
};
|
||||
|
||||
export type CrashTrackerNotifier = (args: {
|
||||
pluginId: string;
|
||||
tenantId: string;
|
||||
failureCount: number;
|
||||
reason: string;
|
||||
correlationId: string;
|
||||
}) => Promise<void>;
|
||||
|
||||
export type CrashTrackerDisableFn = (args: {
|
||||
pluginId: string;
|
||||
tenantId: string;
|
||||
failureCount: number;
|
||||
reason: string;
|
||||
correlationId: string;
|
||||
}) => Promise<void>;
|
||||
|
||||
export type CrashTrackerOptions = {
|
||||
/** Failure count required to flip the kill switch. Default 10. */
|
||||
threshold?: number;
|
||||
/** Window during which failures count. Default 10 minutes. */
|
||||
windowMs?: number;
|
||||
/** Optional logger for crash + auto-disable events. */
|
||||
logger?: {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type CrashTrackerDeps = {
|
||||
store: CrashTrackerStore;
|
||||
disablePlugin: CrashTrackerDisableFn;
|
||||
notify?: CrashTrackerNotifier;
|
||||
options?: CrashTrackerOptions;
|
||||
};
|
||||
|
||||
export type CrashTracker = {
|
||||
recordFailure(args: { pluginId: string; tenantId: string; reason: string; correlationId?: string }): Promise<{
|
||||
failureCount: number;
|
||||
autoDisabled: boolean;
|
||||
}>;
|
||||
recordSuccess(args: { pluginId: string; tenantId: string }): Promise<{ failureCount: number }>;
|
||||
getFailureCount(args: { pluginId: string; tenantId: string }): Promise<number>;
|
||||
};
|
||||
|
||||
const DEFAULT_THRESHOLD = 10;
|
||||
const DEFAULT_WINDOW_MS = 600_000;
|
||||
|
||||
function key(pluginId: string, tenantId: string): string {
|
||||
return `plugin:crash:${pluginId}:${tenantId}`;
|
||||
}
|
||||
|
||||
function getAutoDisableCorrelationId(correlationId: string | undefined, failureCount: number): string {
|
||||
return correlationId ?? `missing-invocation:${failureCount}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tracker bound to a SharedStore-shaped backend. The returned
|
||||
* object is stateless — multiple workers can share the same store keys
|
||||
* because all operations are atomic (`incr`, `expire`).
|
||||
*/
|
||||
export function createCrashTracker(deps: CrashTrackerDeps): CrashTracker {
|
||||
const threshold = deps.options?.threshold ?? DEFAULT_THRESHOLD;
|
||||
const windowMs = deps.options?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const ttlSeconds = Math.max(1, Math.ceil(windowMs / 1000));
|
||||
|
||||
return {
|
||||
async recordFailure({ pluginId, tenantId, reason, correlationId }) {
|
||||
const k = key(pluginId, tenantId);
|
||||
const count = await deps.store.incr(k, 1);
|
||||
// On first failure (count == 1) set the TTL so the window slides
|
||||
// from the FIRST observed failure, not the latest.
|
||||
if (count === 1) {
|
||||
await deps.store.expire(k, ttlSeconds);
|
||||
}
|
||||
deps.options?.logger?.warn?.(
|
||||
{ pluginId, tenantId, failureCount: count, reason },
|
||||
"plugin.crash.recorded"
|
||||
);
|
||||
if (count >= threshold) {
|
||||
const autoDisableCorrelationId = getAutoDisableCorrelationId(correlationId, count);
|
||||
try {
|
||||
await deps.disablePlugin({
|
||||
pluginId,
|
||||
tenantId,
|
||||
failureCount: count,
|
||||
reason,
|
||||
correlationId: autoDisableCorrelationId,
|
||||
});
|
||||
} catch (err) {
|
||||
deps.options?.logger?.error?.(
|
||||
{ pluginId, tenantId, error: err instanceof Error ? err.message : String(err) },
|
||||
"plugin.crash.disable_failed"
|
||||
);
|
||||
return { failureCount: count, autoDisabled: false };
|
||||
}
|
||||
if (deps.notify) {
|
||||
try {
|
||||
await deps.notify({
|
||||
pluginId,
|
||||
tenantId,
|
||||
failureCount: count,
|
||||
reason,
|
||||
correlationId: autoDisableCorrelationId,
|
||||
});
|
||||
} catch (err) {
|
||||
deps.options?.logger?.error?.(
|
||||
{ pluginId, tenantId, error: err instanceof Error ? err.message : String(err) },
|
||||
"plugin.crash.notify_failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reset the counter so a re-enable starts from zero.
|
||||
await deps.store.del(k);
|
||||
deps.options?.logger?.info(
|
||||
{ pluginId, tenantId, failureCount: count },
|
||||
"plugin.crash.auto_disabled"
|
||||
);
|
||||
return { failureCount: count, autoDisabled: true };
|
||||
}
|
||||
return { failureCount: count, autoDisabled: false };
|
||||
},
|
||||
|
||||
async recordSuccess({ pluginId, tenantId }) {
|
||||
const k = key(pluginId, tenantId);
|
||||
const raw = await deps.store.get(k);
|
||||
const current = raw === null ? 0 : Number.parseInt(raw, 10) || 0;
|
||||
if (current <= 0) {
|
||||
return { failureCount: 0 };
|
||||
}
|
||||
const next = current - 1;
|
||||
if (next <= 0) {
|
||||
await deps.store.del(k);
|
||||
return { failureCount: 0 };
|
||||
}
|
||||
await deps.store.set(k, String(next), { ttlSeconds });
|
||||
return { failureCount: next };
|
||||
},
|
||||
|
||||
async getFailureCount({ pluginId, tenantId }) {
|
||||
const raw = await deps.store.get(key(pluginId, tenantId));
|
||||
return raw === null ? 0 : Number.parseInt(raw, 10) || 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Phase 06 #22 — Plugin hook emission helper.
|
||||
*
|
||||
* Wraps `publishOutboxEvent` so host call-sites stay one-line:
|
||||
*
|
||||
* await emitPluginHook(tx, PLUGIN_HOOK_TENANT_CREATED, payload);
|
||||
*
|
||||
* The helper:
|
||||
* 1. Validates the payload against the registered Zod schema.
|
||||
* 2. Publishes a `plugin.hook.<name>` outbox event inside the
|
||||
* surrounding Prisma transaction (atomic with the host write).
|
||||
* 3. Returns the persisted OutboxEvent row so callers can correlate
|
||||
* it in audit logs.
|
||||
*
|
||||
* Bad payloads throw {@link PluginHookValidationError} — callers MUST
|
||||
* either handle it (when the payload is user-derived) or let it
|
||||
* propagate so the host write rolls back.
|
||||
*
|
||||
* Subscriber resolution + fan-out into one outbox row per installed
|
||||
* plugin is intentionally deferred to the worker side: a single
|
||||
* `plugin.hook.<name>` event lands in the outbox queue and the
|
||||
* downstream consumer (worker) expands it into per-plugin invocations.
|
||||
* This keeps the producer path (web request) cheap and avoids
|
||||
* coupling the request thread to the Plugin table.
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
validatePluginHookPayload,
|
||||
type PluginHookDefinition,
|
||||
} from "./registry.js";
|
||||
|
||||
/**
|
||||
* JSON-compatible value type, structurally identical to
|
||||
* `Prisma.InputJsonValue` without taking the dependency on
|
||||
* `@toolctrl/db` (which would cycle through the worker bootstrap).
|
||||
*/
|
||||
export type PluginHookJsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: PluginHookJsonValue }
|
||||
| ReadonlyArray<PluginHookJsonValue>;
|
||||
|
||||
export class PluginHookValidationError extends Error {
|
||||
public override readonly name = "PluginHookValidationError";
|
||||
public readonly hookName: string;
|
||||
public readonly issues: ReadonlyArray<z.ZodIssue>;
|
||||
constructor(hookName: string, issues: ReadonlyArray<z.ZodIssue>) {
|
||||
super(`Invalid payload for plugin hook '${hookName}': ${JSON.stringify(issues)}`);
|
||||
this.hookName = hookName;
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal publish surface — kept identical to the @toolctrl/db helper
|
||||
* so this package does not have to depend on @toolctrl/db directly
|
||||
* (avoids a cycle with the worker bootstrap that pulls plugin-runtime
|
||||
* via @toolctrl/db).
|
||||
*/
|
||||
export type PluginHookOutboxPublisher = (
|
||||
tx: unknown,
|
||||
args: {
|
||||
tenantId?: string | null;
|
||||
type: string;
|
||||
payload: PluginHookJsonValue;
|
||||
dispatchKey?: string | null;
|
||||
}
|
||||
) => Promise<unknown>;
|
||||
|
||||
export type EmitPluginHookOptions = {
|
||||
tenantId?: string | null;
|
||||
/**
|
||||
* Optional idempotency key. When two writers might emit the same
|
||||
* hook for the same business object (e.g. two onboarding paths both
|
||||
* creating the same tenant) supplying a stable key prevents
|
||||
* duplicate downstream invocations.
|
||||
*/
|
||||
dispatchKey?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate + publish a plugin-hook outbox event inside a transaction.
|
||||
* The transaction client is intentionally typed as `unknown` so this
|
||||
* package keeps zero runtime dependency on @toolctrl/db — the caller
|
||||
* (a `.server.ts` file in apps/web) already imports the prisma types
|
||||
* and passes the correct shape.
|
||||
*/
|
||||
export async function emitPluginHook<TPayload>(
|
||||
tx: unknown,
|
||||
def: PluginHookDefinition<TPayload>,
|
||||
payload: TPayload,
|
||||
publish: PluginHookOutboxPublisher,
|
||||
options: EmitPluginHookOptions = {}
|
||||
): Promise<void> {
|
||||
const validation = validatePluginHookPayload(def, payload);
|
||||
if (!validation.ok) {
|
||||
throw new PluginHookValidationError(def.name, validation.issues);
|
||||
}
|
||||
await publish(tx, {
|
||||
tenantId: options.tenantId ?? null,
|
||||
type: def.outboxEventType,
|
||||
payload: validation.payload as unknown as PluginHookJsonValue,
|
||||
dispatchKey: options.dispatchKey ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PLUGIN_HOOK_ENTITY_CREATED,
|
||||
PLUGIN_HOOK_ENTITY_DELETED,
|
||||
PLUGIN_HOOK_ENTITY_UPDATED,
|
||||
PLUGIN_HOOK_TENANT_CREATED,
|
||||
PLUGIN_HOOK_USER_CREATED,
|
||||
PLUGIN_HOOK_WORKFLOW_CREATED,
|
||||
PLUGIN_HOOK_WORKFLOW_DELETED,
|
||||
PLUGIN_HOOK_WORKFLOW_EXECUTED,
|
||||
PLUGIN_HOOK_WORKFLOW_FAILED,
|
||||
PLUGIN_HOOK_WORKFLOW_UPDATED,
|
||||
PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES,
|
||||
__resetPluginHookRegistryForTests,
|
||||
getPluginHook,
|
||||
getUnavailablePluginHookNames,
|
||||
isPluginHookProductionAvailable,
|
||||
listPluginHooks,
|
||||
registerHook,
|
||||
validatePluginHookPayload,
|
||||
} from "./registry.js";
|
||||
import { PluginHookValidationError, emitPluginHook } from "./emit.js";
|
||||
|
||||
afterEach(() => {
|
||||
__resetPluginHookRegistryForTests();
|
||||
});
|
||||
|
||||
describe("plugin hook registry", () => {
|
||||
test("registers the foundation hooks", () => {
|
||||
const hooks = listPluginHooks().map((h) => h.name).sort();
|
||||
expect(hooks).toContain("tenant.created");
|
||||
expect(hooks).toContain("user.created");
|
||||
expect(hooks).toContain("entity.created");
|
||||
expect(hooks).toContain("entity.updated");
|
||||
expect(hooks).toContain("entity.deleted");
|
||||
expect(hooks).toContain("workflow.created");
|
||||
expect(hooks).toContain("workflow.updated");
|
||||
expect(hooks).toContain("workflow.deleted");
|
||||
expect(hooks).toContain("workflow.executed");
|
||||
expect(hooks).toContain("workflow.failed");
|
||||
});
|
||||
|
||||
test("each hook has an outboxEventType matching plugin.hook.<name>", () => {
|
||||
for (const hook of listPluginHooks()) {
|
||||
expect(hook.outboxEventType).toBe(`plugin.hook.${hook.name}`);
|
||||
// Must conform to the outbox eventType pattern.
|
||||
expect(hook.outboxEventType).toMatch(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*){2,}$/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("marks only foundation hooks as production-available", () => {
|
||||
expect(PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES).toEqual([
|
||||
"tenant.created",
|
||||
"user.created",
|
||||
"entity.created",
|
||||
"entity.updated",
|
||||
"entity.deleted",
|
||||
"workflow.created",
|
||||
"workflow.updated",
|
||||
"workflow.deleted",
|
||||
"workflow.executed",
|
||||
"workflow.failed",
|
||||
]);
|
||||
expect(isPluginHookProductionAvailable("tenant.created")).toBe(true);
|
||||
expect(isPluginHookProductionAvailable("workflow.failed")).toBe(true);
|
||||
|
||||
registerHook({
|
||||
name: "billing.paid",
|
||||
outboxEventType: "plugin.hook.billing.paid",
|
||||
payloadSchema: z.object({ invoiceId: z.string() }),
|
||||
});
|
||||
|
||||
expect(getPluginHook("billing.paid")).toBeDefined();
|
||||
expect(isPluginHookProductionAvailable("billing.paid")).toBe(false);
|
||||
expect(getUnavailablePluginHookNames([{ name: "tenant.created" }, { name: "billing.paid" }, "billing.paid", "unknown.hook"])).toEqual([
|
||||
"billing.paid",
|
||||
]);
|
||||
});
|
||||
|
||||
test("getPluginHook returns the registered definition", () => {
|
||||
expect(getPluginHook("tenant.created")).toBe(PLUGIN_HOOK_TENANT_CREATED);
|
||||
expect(getPluginHook("unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("validates a well-formed tenant.created payload", () => {
|
||||
const result = validatePluginHookPayload(PLUGIN_HOOK_TENANT_CREATED, {
|
||||
tenantId: "t-1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.com",
|
||||
plan: "free",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects payload missing required fields", () => {
|
||||
const result = validatePluginHookPayload(PLUGIN_HOOK_USER_CREATED, {
|
||||
email: "u@example.com",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("entity.* hooks accept the canonical payload", () => {
|
||||
const payload = {
|
||||
entityType: "row",
|
||||
entityId: "row-1",
|
||||
tenantId: "t-1",
|
||||
userId: "u-1",
|
||||
occurredAt: "2026-05-17T10:00:00.000Z",
|
||||
};
|
||||
expect(validatePluginHookPayload(PLUGIN_HOOK_ENTITY_CREATED, payload).ok).toBe(true);
|
||||
expect(validatePluginHookPayload(PLUGIN_HOOK_ENTITY_UPDATED, { ...payload, changedFields: ["a"] }).ok).toBe(true);
|
||||
expect(validatePluginHookPayload(PLUGIN_HOOK_ENTITY_DELETED, payload).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects raw or sensitive fields on public hook payloads", () => {
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_TENANT_CREATED, {
|
||||
tenantId: "t-1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.com",
|
||||
plan: "free",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
rawPayload: { billingEmail: "billing@example.com" },
|
||||
}).ok
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_USER_CREATED, {
|
||||
userId: "u-1",
|
||||
tenantId: "t-1",
|
||||
email: "u@example.com",
|
||||
locale: "en",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
passwordHash: "secret",
|
||||
}).ok
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_ENTITY_CREATED, {
|
||||
entityType: "row",
|
||||
entityId: "row-1",
|
||||
tenantId: "t-1",
|
||||
userId: "u-1",
|
||||
beforeData: { apiKey: "secret" },
|
||||
occurredAt: "2026-05-17T10:00:00.000Z",
|
||||
}).ok
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("workflow.executed validates terminal tenant-scoped execution metadata without payload bodies", () => {
|
||||
const valid = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(valid.ok).toBe(true);
|
||||
|
||||
const legacyWithoutUserId = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(legacyWithoutUserId.ok).toBe(true);
|
||||
|
||||
const systemExecution = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: null,
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(systemExecution.ok).toBe(true);
|
||||
|
||||
const withLeakedOutput = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
output: { secret: "nope" },
|
||||
});
|
||||
expect(withLeakedOutput.ok).toBe(false);
|
||||
|
||||
const waiting = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "manual",
|
||||
status: "waitingBlock",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(waiting.ok).toBe(false);
|
||||
|
||||
const withRawError = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
error: "Provider failed with token=sk_live_secret",
|
||||
});
|
||||
expect(withRawError.ok).toBe(false);
|
||||
|
||||
const successWithErrorCode = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(successWithErrorCode.ok).toBe(false);
|
||||
|
||||
const errorWithoutCode = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_EXECUTED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(errorWithoutCode.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("workflow.failed validates failure-only execution metadata without raw error bodies", () => {
|
||||
const valid = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_FAILED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(valid.ok).toBe(true);
|
||||
|
||||
const successfulExecution = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_FAILED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "success",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(successfulExecution.ok).toBe(false);
|
||||
|
||||
const withoutCode = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_FAILED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
});
|
||||
expect(withoutCode.ok).toBe(false);
|
||||
|
||||
const withRawError = validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_FAILED, {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
workflowExecutionId: "execution_1",
|
||||
type: "trigger",
|
||||
status: "error",
|
||||
durationMs: 12,
|
||||
errorCode: "workflow_execution_failed",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
error: "Provider failed with token=sk_live_secret",
|
||||
});
|
||||
expect(withRawError.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("workflow definition hooks validate metadata without workflow bodies", () => {
|
||||
const payload = {
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_1",
|
||||
status: "draft",
|
||||
appliesToAllTenants: false,
|
||||
occurredAt: "2026-05-26T20:00:00.000Z",
|
||||
};
|
||||
|
||||
expect(validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_CREATED, payload).ok).toBe(true);
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_UPDATED, {
|
||||
...payload,
|
||||
status: "live",
|
||||
changedFields: ["status"],
|
||||
}).ok
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_DELETED, {
|
||||
...payload,
|
||||
tenantId: null,
|
||||
userId: null,
|
||||
appliesToAllTenants: true,
|
||||
}).ok
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_UPDATED, {
|
||||
...payload,
|
||||
changedFields: ["blocks"],
|
||||
blocks: [{ id: "block_1", input: { token: "secret" } }],
|
||||
}).ok
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_CREATED, {
|
||||
...payload,
|
||||
credentials: { apiKey: "secret" },
|
||||
}).ok
|
||||
).toBe(false);
|
||||
expect(
|
||||
validatePluginHookPayload(PLUGIN_HOOK_WORKFLOW_CREATED, {
|
||||
...payload,
|
||||
input: { raw: true },
|
||||
}).ok
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("registerHook rejects duplicate name", () => {
|
||||
expect(() =>
|
||||
registerHook({
|
||||
name: "tenant.created",
|
||||
outboxEventType: "plugin.hook.tenant.created",
|
||||
payloadSchema: z.object({}),
|
||||
})
|
||||
).toThrow(/already registered/u);
|
||||
});
|
||||
|
||||
test("registerHook rejects malformed outboxEventType", () => {
|
||||
expect(() =>
|
||||
registerHook({
|
||||
name: "custom.singleword",
|
||||
// missing second dot-segment after plugin.hook
|
||||
outboxEventType: "plugin.hook.singleword" as `plugin.hook.${string}`,
|
||||
payloadSchema: z.object({}),
|
||||
})
|
||||
).toThrow(/must end in/u);
|
||||
});
|
||||
|
||||
test("registerHook rejects malformed or mismatched hook names", () => {
|
||||
expect(() =>
|
||||
registerHook({
|
||||
name: "Tenant.created",
|
||||
outboxEventType: "plugin.hook.tenant.created",
|
||||
payloadSchema: z.object({}),
|
||||
})
|
||||
).toThrow(/lower-case dotted/u);
|
||||
|
||||
expect(() =>
|
||||
registerHook({
|
||||
name: "billing.invoice.paid",
|
||||
outboxEventType: "plugin.hook.billing.invoice.created",
|
||||
payloadSchema: z.object({}),
|
||||
})
|
||||
).toThrow(/must match hook name/u);
|
||||
|
||||
expect(getPluginHook("billing.invoice.paid")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitPluginHook", () => {
|
||||
test("publishes a validated outbox event for a valid payload", async () => {
|
||||
const publish = vi.fn().mockResolvedValue({ id: "evt-1" });
|
||||
await emitPluginHook(
|
||||
{ fake: "tx" },
|
||||
PLUGIN_HOOK_TENANT_CREATED,
|
||||
{
|
||||
tenantId: "t-1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.com",
|
||||
plan: "free",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
},
|
||||
publish,
|
||||
{ tenantId: "t-1", dispatchKey: "tenant:t-1:created" }
|
||||
);
|
||||
expect(publish).toHaveBeenCalledOnce();
|
||||
const args = publish.mock.calls[0]?.[1] as { type: string; tenantId: string | null; dispatchKey: string | null };
|
||||
expect(args.type).toBe("plugin.hook.tenant.created");
|
||||
expect(args.tenantId).toBe("t-1");
|
||||
expect(args.dispatchKey).toBe("tenant:t-1:created");
|
||||
});
|
||||
|
||||
test("throws PluginHookValidationError when payload is invalid", async () => {
|
||||
const publish = vi.fn();
|
||||
await expect(
|
||||
emitPluginHook(
|
||||
{},
|
||||
PLUGIN_HOOK_USER_CREATED,
|
||||
{ email: "not-an-email" } as never,
|
||||
publish
|
||||
)
|
||||
).rejects.toBeInstanceOf(PluginHookValidationError);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Phase 06 #22 — Plugin hook registry.
|
||||
*
|
||||
* Central, immutable registry of hook names with their Zod payload
|
||||
* schemas. Host call-sites import {@link emitPluginHook} to publish a
|
||||
* `plugin.hook.<name>` outbox event; the registry validates the payload
|
||||
* once before the row is written so a bad payload never reaches the
|
||||
* worker / sandbox boundary.
|
||||
*
|
||||
* Foundation scope (the wider plan lists ~18 hooks): we ship the five
|
||||
* most-load-bearing hook points and gate the rest behind {@link registerHook}
|
||||
* so follow-ups can extend the registry without touching this module.
|
||||
*
|
||||
* - `tenant.created`
|
||||
* - `user.created`
|
||||
* - `entity.created`
|
||||
* - `entity.updated`
|
||||
* - `entity.deleted`
|
||||
* - `workflow.created`
|
||||
* - `workflow.updated`
|
||||
* - `workflow.deleted`
|
||||
* - `workflow.executed`
|
||||
* - `workflow.failed`
|
||||
*
|
||||
* Hooks NOT yet wired (output-shaping, billing, auth, webhooks, email,
|
||||
* chatbot, export, integrations, api-keys, workflows) are documented in
|
||||
* `docs/improvement-plan/06-plugin-system/hooks-lifecycle.md` and will
|
||||
* land in follow-up phases alongside the marketplace + audit-docs tasks.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const TenantCreatedSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
ownerEmail: z.string().email().nullable().optional(),
|
||||
plan: z.string().min(1).nullable().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const UserCreatedSchema = z
|
||||
.object({
|
||||
userId: z.string().min(1),
|
||||
tenantId: z.string().min(1).nullable().optional(),
|
||||
email: z.string().email(),
|
||||
locale: z.string().min(2).max(10).nullable().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const EntityCrudPayloadSchema = z
|
||||
.object({
|
||||
entityType: z.string().min(1),
|
||||
entityId: z.string().min(1),
|
||||
tenantId: z.string().min(1).nullable(),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
changedFields: z.array(z.string()).optional(),
|
||||
occurredAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const WorkflowExecutionPayloadBaseSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1),
|
||||
workflowId: z.string().min(1),
|
||||
workflowExecutionId: z.string().min(1),
|
||||
userId: z.string().min(1).nullable().optional(),
|
||||
type: z.enum(["manual", "api", "stream", "trigger"]),
|
||||
status: z.enum(["success", "error"]),
|
||||
durationMs: z.number().int().nonnegative(),
|
||||
errorCode: z.string().min(1).max(128).nullable(),
|
||||
appliesToAllTenants: z.boolean(),
|
||||
occurredAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const WorkflowExecutedPayloadSchema = WorkflowExecutionPayloadBaseSchema.superRefine((payload, ctx) => {
|
||||
if (payload.status === "success" && payload.errorCode !== null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["errorCode"],
|
||||
message: "workflow.executed success payloads must not include errorCode.",
|
||||
});
|
||||
}
|
||||
if (payload.status === "error" && payload.errorCode === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["errorCode"],
|
||||
message: "workflow.executed error payloads require errorCode.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const WorkflowFailedPayloadSchema = WorkflowExecutionPayloadBaseSchema.extend({
|
||||
status: z.literal("error"),
|
||||
errorCode: z.string().min(1).max(128),
|
||||
}).strict();
|
||||
|
||||
const WorkflowDefinitionPayloadSchema = z
|
||||
.object({
|
||||
tenantId: z.string().min(1).nullable(),
|
||||
workflowId: z.string().min(1),
|
||||
userId: z.string().min(1).nullable(),
|
||||
status: z.enum(["draft", "live", "archived"]),
|
||||
appliesToAllTenants: z.boolean(),
|
||||
changedFields: z.array(z.string()).optional(),
|
||||
occurredAt: z.string().datetime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Hook registry row. `outboxEventType` is the canonical eventType used
|
||||
* by {@link publishOutboxEvent} and routed by the worker dispatcher.
|
||||
*/
|
||||
export type PluginHookDefinition<TPayload> = {
|
||||
readonly name: string;
|
||||
readonly outboxEventType: `plugin.hook.${string}`;
|
||||
readonly payloadSchema: z.ZodType<TPayload>;
|
||||
};
|
||||
|
||||
type AnyPluginHookDefinition = PluginHookDefinition<unknown>;
|
||||
|
||||
// Internal mutable map; only mutated via `registerHook`. Exposed
|
||||
// read-only to consumers.
|
||||
const REGISTRY = new Map<string, AnyPluginHookDefinition>();
|
||||
const HOOK_NAME_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/u;
|
||||
|
||||
export const PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES = [
|
||||
"tenant.created",
|
||||
"user.created",
|
||||
"entity.created",
|
||||
"entity.updated",
|
||||
"entity.deleted",
|
||||
"workflow.created",
|
||||
"workflow.updated",
|
||||
"workflow.deleted",
|
||||
"workflow.executed",
|
||||
"workflow.failed",
|
||||
] as const;
|
||||
|
||||
const PRODUCTION_AVAILABLE_HOOK_SET = new Set<string>(PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES);
|
||||
|
||||
function defineHook<TPayload>(def: PluginHookDefinition<TPayload>): PluginHookDefinition<TPayload> {
|
||||
if (REGISTRY.has(def.name)) {
|
||||
throw new Error(`Plugin hook '${def.name}' is already registered.`);
|
||||
}
|
||||
// outboxEventType must match the publishOutboxEvent pattern
|
||||
// `<domain>.<entity>.<verb>` — `plugin.hook.<name>` qualifies as
|
||||
// long as `<name>` has at least one dot (e.g. `tenant.created`).
|
||||
// We enforce that here so registration fails loudly at module-load.
|
||||
if (!HOOK_NAME_PATTERN.test(def.name)) {
|
||||
throw new Error(`Plugin hook name '${def.name}' must be lower-case dotted '<entity>.<verb>'.`);
|
||||
}
|
||||
const tail = def.outboxEventType.slice("plugin.hook.".length);
|
||||
if (!HOOK_NAME_PATTERN.test(tail)) {
|
||||
throw new Error(
|
||||
`Plugin hook outboxEventType '${def.outboxEventType}' must end in '<entity>.<verb>'.`
|
||||
);
|
||||
}
|
||||
if (def.outboxEventType !== `plugin.hook.${def.name}`) {
|
||||
throw new Error(`Plugin hook outboxEventType '${def.outboxEventType}' must match hook name '${def.name}'.`);
|
||||
}
|
||||
REGISTRY.set(def.name, def as AnyPluginHookDefinition);
|
||||
return def;
|
||||
}
|
||||
|
||||
export const PLUGIN_HOOK_TENANT_CREATED = defineHook({
|
||||
name: "tenant.created",
|
||||
outboxEventType: "plugin.hook.tenant.created",
|
||||
payloadSchema: TenantCreatedSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_USER_CREATED = defineHook({
|
||||
name: "user.created",
|
||||
outboxEventType: "plugin.hook.user.created",
|
||||
payloadSchema: UserCreatedSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_ENTITY_CREATED = defineHook({
|
||||
name: "entity.created",
|
||||
outboxEventType: "plugin.hook.entity.created",
|
||||
payloadSchema: EntityCrudPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_ENTITY_UPDATED = defineHook({
|
||||
name: "entity.updated",
|
||||
outboxEventType: "plugin.hook.entity.updated",
|
||||
payloadSchema: EntityCrudPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_ENTITY_DELETED = defineHook({
|
||||
name: "entity.deleted",
|
||||
outboxEventType: "plugin.hook.entity.deleted",
|
||||
payloadSchema: EntityCrudPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_WORKFLOW_EXECUTED = defineHook({
|
||||
name: "workflow.executed",
|
||||
outboxEventType: "plugin.hook.workflow.executed",
|
||||
payloadSchema: WorkflowExecutedPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_WORKFLOW_FAILED = defineHook({
|
||||
name: "workflow.failed",
|
||||
outboxEventType: "plugin.hook.workflow.failed",
|
||||
payloadSchema: WorkflowFailedPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_WORKFLOW_CREATED = defineHook({
|
||||
name: "workflow.created",
|
||||
outboxEventType: "plugin.hook.workflow.created",
|
||||
payloadSchema: WorkflowDefinitionPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_WORKFLOW_UPDATED = defineHook({
|
||||
name: "workflow.updated",
|
||||
outboxEventType: "plugin.hook.workflow.updated",
|
||||
payloadSchema: WorkflowDefinitionPayloadSchema,
|
||||
});
|
||||
|
||||
export const PLUGIN_HOOK_WORKFLOW_DELETED = defineHook({
|
||||
name: "workflow.deleted",
|
||||
outboxEventType: "plugin.hook.workflow.deleted",
|
||||
payloadSchema: WorkflowDefinitionPayloadSchema,
|
||||
});
|
||||
|
||||
export type PluginHookTenantCreatedPayload = z.infer<typeof TenantCreatedSchema>;
|
||||
export type PluginHookUserCreatedPayload = z.infer<typeof UserCreatedSchema>;
|
||||
export type PluginHookEntityCrudPayload = z.infer<typeof EntityCrudPayloadSchema>;
|
||||
export type PluginHookWorkflowExecutedPayload = z.infer<typeof WorkflowExecutedPayloadSchema>;
|
||||
export type PluginHookWorkflowFailedPayload = z.infer<typeof WorkflowFailedPayloadSchema>;
|
||||
export type PluginHookWorkflowDefinitionPayload = z.infer<typeof WorkflowDefinitionPayloadSchema>;
|
||||
|
||||
/**
|
||||
* Register an additional hook at module-load time. Used by tests and
|
||||
* by follow-up tasks that extend the registry. Throws when the hook
|
||||
* name or outboxEventType is already taken.
|
||||
*/
|
||||
export function registerHook<TPayload>(def: PluginHookDefinition<TPayload>): PluginHookDefinition<TPayload> {
|
||||
return defineHook(def);
|
||||
}
|
||||
|
||||
export function getPluginHook(name: string): AnyPluginHookDefinition | undefined {
|
||||
return REGISTRY.get(name);
|
||||
}
|
||||
|
||||
export function listPluginHooks(): ReadonlyArray<AnyPluginHookDefinition> {
|
||||
return Array.from(REGISTRY.values());
|
||||
}
|
||||
|
||||
export function isPluginHookProductionAvailable(value: unknown): value is (typeof PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES)[number] {
|
||||
return typeof value === "string" && PRODUCTION_AVAILABLE_HOOK_SET.has(value);
|
||||
}
|
||||
|
||||
export function getUnavailablePluginHookNames(hooks: ReadonlyArray<string | { name: string }>): string[] {
|
||||
const unavailable = new Set<string>();
|
||||
for (const hook of hooks) {
|
||||
const name = typeof hook === "string" ? hook : hook.name;
|
||||
if (REGISTRY.has(name) && !isPluginHookProductionAvailable(name)) {
|
||||
unavailable.add(name);
|
||||
}
|
||||
}
|
||||
return [...unavailable].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse-and-typecheck a payload against the registered schema. Returns
|
||||
* the canonical (Zod-parsed) value on success or a structured failure
|
||||
* with the original Zod issues for logging.
|
||||
*/
|
||||
export type PluginHookValidationResult<TPayload> =
|
||||
| { ok: true; payload: TPayload }
|
||||
| { ok: false; issues: ReadonlyArray<z.ZodIssue> };
|
||||
|
||||
export function validatePluginHookPayload<TPayload>(
|
||||
def: PluginHookDefinition<TPayload>,
|
||||
payload: unknown
|
||||
): PluginHookValidationResult<TPayload> {
|
||||
const result = def.payloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
return { ok: false, issues: result.error.issues };
|
||||
}
|
||||
return { ok: true, payload: result.data };
|
||||
}
|
||||
|
||||
/** Reset hook for tests only. Not exported from package index. */
|
||||
export function __resetPluginHookRegistryForTests(): void {
|
||||
REGISTRY.clear();
|
||||
defineHook(PLUGIN_HOOK_TENANT_CREATED);
|
||||
defineHook(PLUGIN_HOOK_USER_CREATED);
|
||||
defineHook(PLUGIN_HOOK_ENTITY_CREATED);
|
||||
defineHook(PLUGIN_HOOK_ENTITY_UPDATED);
|
||||
defineHook(PLUGIN_HOOK_ENTITY_DELETED);
|
||||
defineHook(PLUGIN_HOOK_WORKFLOW_EXECUTED);
|
||||
defineHook(PLUGIN_HOOK_WORKFLOW_FAILED);
|
||||
defineHook(PLUGIN_HOOK_WORKFLOW_CREATED);
|
||||
defineHook(PLUGIN_HOOK_WORKFLOW_UPDATED);
|
||||
defineHook(PLUGIN_HOOK_WORKFLOW_DELETED);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @toolctrl/plugin-runtime — Phase 06 Task #21 foundation.
|
||||
*
|
||||
* Scaffold-only entry. The real exports are wired in the subsequent
|
||||
* commits of this task (manifest schema, signature verifier, sandbox
|
||||
* wrapper, capability gateway). See
|
||||
* docs/improvement-plan/06-plugin-system/wasm-sandbox.md.
|
||||
*/
|
||||
|
||||
export const PLUGIN_RUNTIME_PACKAGE_VERSION = "0.0.0";
|
||||
|
||||
export {
|
||||
PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES,
|
||||
PLUGIN_CAPABILITY_METADATA,
|
||||
PLUGIN_CAPABILITY_NAMES,
|
||||
getPluginCapabilityMetadata,
|
||||
getUnavailablePluginCapabilityNames,
|
||||
isPluginCapabilityHostBindingAvailable,
|
||||
isPluginCapabilityName,
|
||||
type PluginCapabilityMetadata,
|
||||
type PluginCapabilityName,
|
||||
type PluginCapabilityRisk,
|
||||
} from "./capabilities.js";
|
||||
export {
|
||||
PluginManifestSchema,
|
||||
parsePluginManifest,
|
||||
manifestDeclaresCapability,
|
||||
type PluginCapability,
|
||||
type PluginManifest,
|
||||
type PluginManifestParseResult,
|
||||
} from "./manifest.js";
|
||||
export {
|
||||
canonicalPluginSignaturePayload,
|
||||
parseTrustedPublicKeys,
|
||||
verifyPluginSignature,
|
||||
type VerifyPluginSignatureFailure,
|
||||
type VerifyPluginSignatureInput,
|
||||
type VerifyPluginSignatureResult,
|
||||
} from "./signature.js";
|
||||
export {
|
||||
createPluginSandbox,
|
||||
type PluginInvocationFailure,
|
||||
type PluginInvocationResult,
|
||||
type PluginSandbox,
|
||||
type PluginSandboxOptions,
|
||||
} from "./runtime.js";
|
||||
export {
|
||||
CapabilityDeniedError,
|
||||
createCapabilityGateway,
|
||||
type CapabilityCallInfo,
|
||||
type CapabilityDenyReason,
|
||||
type CapabilityGateway,
|
||||
type CapabilityGatewayOptions,
|
||||
type CapabilityHostFn,
|
||||
type CapabilityHostFnMap,
|
||||
} from "./capability-gateway.js";
|
||||
export {
|
||||
getMissingCapabilityGrantNames,
|
||||
isCapabilityGrantScopeSatisfied,
|
||||
type PluginCapabilityGrant,
|
||||
} from "./capability-grants.js";
|
||||
export { isCapabilityCallWithinScope } from "./capability-scope.js";
|
||||
export {
|
||||
PluginCapabilityUseAuditTruncatedError,
|
||||
createPluginCapabilityUseRecorder,
|
||||
hashCapabilityPayload,
|
||||
type PluginCapabilityUseBatch,
|
||||
type PluginCapabilityUseEntry,
|
||||
type PluginCapabilityUseLogger,
|
||||
type PluginCapabilityUseOutcome,
|
||||
type PluginCapabilityUseRecord,
|
||||
type PluginCapabilityUseRecorder,
|
||||
type PluginCapabilityUseRecorderOptions,
|
||||
type PluginCapabilityUseSink,
|
||||
} from "./audit.js";
|
||||
export {
|
||||
CHAIN_FIELDS,
|
||||
canonicalisePluginCapabilityUseRow,
|
||||
computeRowHash,
|
||||
stampPluginCapabilityUseChain,
|
||||
verifyPluginCapabilityUseChain,
|
||||
type ChainBreakReason,
|
||||
type PluginCapabilityUseChainRow,
|
||||
type StampedRow,
|
||||
type VerifyChainResult,
|
||||
} from "./audit-chain.js";
|
||||
export {
|
||||
PLUGIN_HOOK_ENTITY_CREATED,
|
||||
PLUGIN_HOOK_ENTITY_DELETED,
|
||||
PLUGIN_HOOK_ENTITY_UPDATED,
|
||||
PLUGIN_HOOK_TENANT_CREATED,
|
||||
PLUGIN_HOOK_USER_CREATED,
|
||||
PLUGIN_HOOK_WORKFLOW_CREATED,
|
||||
PLUGIN_HOOK_WORKFLOW_DELETED,
|
||||
PLUGIN_HOOK_WORKFLOW_EXECUTED,
|
||||
PLUGIN_HOOK_WORKFLOW_FAILED,
|
||||
PLUGIN_HOOK_WORKFLOW_UPDATED,
|
||||
PLUGIN_PRODUCTION_AVAILABLE_HOOK_NAMES,
|
||||
getPluginHook,
|
||||
getUnavailablePluginHookNames,
|
||||
isPluginHookProductionAvailable,
|
||||
listPluginHooks,
|
||||
registerHook,
|
||||
validatePluginHookPayload,
|
||||
type PluginHookDefinition,
|
||||
type PluginHookEntityCrudPayload,
|
||||
type PluginHookTenantCreatedPayload,
|
||||
type PluginHookUserCreatedPayload,
|
||||
type PluginHookValidationResult,
|
||||
type PluginHookWorkflowDefinitionPayload,
|
||||
type PluginHookWorkflowExecutedPayload,
|
||||
type PluginHookWorkflowFailedPayload,
|
||||
} from "./hooks/registry.js";
|
||||
export {
|
||||
PluginHookValidationError,
|
||||
emitPluginHook,
|
||||
type EmitPluginHookOptions,
|
||||
type PluginHookJsonValue,
|
||||
type PluginHookOutboxPublisher,
|
||||
} from "./hooks/emit.js";
|
||||
export {
|
||||
DEFAULT_MAX_STATEMENTS_PER_MIGRATION,
|
||||
PluginMigrationError,
|
||||
applyPluginMigration,
|
||||
findForbiddenKeyword,
|
||||
sanitizePluginIdForSql,
|
||||
splitStatements,
|
||||
stripSqlNoise,
|
||||
tokenizeSqlIdentifiers,
|
||||
validatePluginMigration,
|
||||
type ApplyPluginMigrationDeps,
|
||||
type SqlIdentifierToken,
|
||||
type ValidatedPluginMigration,
|
||||
} from "./migrations.js";
|
||||
export { sanitizePluginOutput, type SanitizationStrictness } from "./sanitize.js";
|
||||
export {
|
||||
createCrashTracker,
|
||||
type CrashTracker,
|
||||
type CrashTrackerDeps,
|
||||
type CrashTrackerDisableFn,
|
||||
type CrashTrackerNotifier,
|
||||
type CrashTrackerOptions,
|
||||
type CrashTrackerStore,
|
||||
} from "./crash-tracker.js";
|
||||
export {
|
||||
isPluginCompatibleWithHost,
|
||||
type PluginVersionCheckIncompatible,
|
||||
type PluginVersionCheckResult,
|
||||
type PluginVersionConstraints,
|
||||
} from "./version-check.js";
|
||||
export {
|
||||
PLUGIN_RUNTIME_PRODUCTION_GATE_ENV,
|
||||
assertPluginRuntimeProductionEnabled,
|
||||
describePluginRuntimeProductionGate,
|
||||
isPluginRuntimeProductionEnabled,
|
||||
type PluginRuntimeEnv,
|
||||
} from "./production-gate.js";
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { manifestDeclaresCapability, parsePluginManifest, PluginManifestSchema } from "./manifest.js";
|
||||
|
||||
const hex64 = "0".repeat(64);
|
||||
const hex128 = "0".repeat(128);
|
||||
const settingsHtmlSha256 = "56a78618698dbd72399e9d07b4f12a9e7910cd4a1b7de41a2f90bd4a33ea338e";
|
||||
const settingsHtmlIntegrity = "sha256-VqeGGGmNvXI5np0HtPEqnnkQzUobfeQaL5C9SjPqM44=";
|
||||
|
||||
const validManifest = {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.hello",
|
||||
version: "1.0.0",
|
||||
name: "Hello",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: hex64,
|
||||
signature: hex128,
|
||||
signedBy: hex64,
|
||||
allowedHosts: ["api.example.com"],
|
||||
capabilities: [{ name: "log" as const }, { name: "http.outbound" as const, scope: { allowedHosts: ["api.example.com"] } }],
|
||||
hooks: [{ name: "onHello", export: "handleHello" }],
|
||||
};
|
||||
|
||||
describe("PluginManifestSchema", () => {
|
||||
test("accepts a well-formed manifest", () => {
|
||||
const parsed = PluginManifestSchema.safeParse(validManifest);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects reverse-DNS-shaped ids that have only one label", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, id: "hello" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-semver versions", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, version: "v1" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unknown capability names (closed enum)", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: [{ name: "filesystem.write" }],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects schemaVersion other than 1", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, schemaVersion: 2 });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects signatures that are not 128 hex chars", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, signature: "abc" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects manifests with no hooks", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, hooks: [] });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unknown top-level fields (strict mode)", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({ ...validManifest, surpriseField: true });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects hosts with a scheme prefix", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
allowedHosts: ["https://api.example.com"],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects localhost, IP, and private-ish outbound hosts", () => {
|
||||
for (const host of ["localhost", "api.localhost", "127.0.0.1", "service.local", "db.internal"]) {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
allowedHosts: [host],
|
||||
});
|
||||
expect(parsed.success, host).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects private-ish hosts inside capability scopes", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["127.0.0.1"] } }],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate and oversized top-level allowed host lists", () => {
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
allowedHosts: ["api.example.com", "api.example.com"],
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
allowedHosts: Array.from({ length: 65 }, (_, index) => `api-${index}.example.com`),
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate and oversized scoped allowed host lists", () => {
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com", "api.example.com"] } }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: Array.from({ length: 33 }, (_, index) => `api-${index}.example.com`) } }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate and oversized capability declarations", () => {
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: [{ name: "log" }, { name: "log" }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
capabilities: Array.from({ length: 33 }, () => ({ name: "log" as const })),
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate and oversized hook declarations", () => {
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
hooks: [
|
||||
{ name: "entity.created", export: "handleCreated" },
|
||||
{ name: "entity.created", export: "handleCreatedAgain" },
|
||||
],
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
hooks: [
|
||||
{ name: "entity.created", export: "handleCreated" },
|
||||
{ name: "entity.updated", export: "handleCreated" },
|
||||
],
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
hooks: Array.from({ length: 65 }, (_, index) => ({ name: `hook.${index}`, export: `handle${index}` })),
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// F-017 — host-version constraint fields are now schema-enforced.
|
||||
test("accepts optional minHostVersion / maxHostVersion when omitted", () => {
|
||||
const parsed = PluginManifestSchema.safeParse(validManifest);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.minHostVersion).toBeUndefined();
|
||||
expect(parsed.data.maxHostVersion).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts well-formed minHostVersion / maxHostVersion SemVer values", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
minHostVersion: "1.0.0",
|
||||
maxHostVersion: "2.0.0-rc.1",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.minHostVersion).toBe("1.0.0");
|
||||
expect(parsed.data.maxHostVersion).toBe("2.0.0-rc.1");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects minHostVersion that is not loose-SemVer", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
minHostVersion: "v1",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects maxHostVersion that is not loose-SemVer", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
maxHostVersion: "latest",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts an optional settings UI bundle entry", () => {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry: "settings/index.html", sha256: settingsHtmlSha256 } },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.ui?.settings?.entry).toBe("settings/index.html");
|
||||
expect(parsed.data.ui?.settings?.sha256).toBe(settingsHtmlSha256);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts settings UI SRI integrity and rejects missing or mismatched integrity metadata", () => {
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry: "settings/index.html", integrity: settingsHtmlIntegrity } },
|
||||
}).success
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry: "settings/index.html" } },
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry: "settings/index.html", sha256: settingsHtmlSha256, integrity: `sha256-${"A".repeat(43)}=` } },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unsafe settings UI entries and unknown ui fields", () => {
|
||||
for (const entry of ["/settings.html", "../settings.html", "settings/../index.html", "https://example.com/settings.html", "settings\\index.html", "settings/index.js"]) {
|
||||
const parsed = PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry } },
|
||||
});
|
||||
expect(parsed.success, entry).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
PluginManifestSchema.safeParse({
|
||||
...validManifest,
|
||||
ui: { settings: { entry: "settings.html", sha256: settingsHtmlSha256 }, surprise: true },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginManifest", () => {
|
||||
test("returns ok=true with the typed manifest on success", () => {
|
||||
const result = parsePluginManifest(validManifest);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.manifest.id).toBe("de.toolctrl.hello");
|
||||
expect(result.manifest.capabilities).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("aggregates Zod issues without throwing", () => {
|
||||
const result = parsePluginManifest({ schemaVersion: 1 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifestDeclaresCapability", () => {
|
||||
test("returns true when the capability is in the list", () => {
|
||||
const result = parsePluginManifest(validManifest);
|
||||
if (!result.ok) throw new Error("fixture invalid");
|
||||
expect(manifestDeclaresCapability(result.manifest, "http.outbound")).toBe(true);
|
||||
expect(manifestDeclaresCapability(result.manifest, "log")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when the capability is not declared", () => {
|
||||
const result = parsePluginManifest(validManifest);
|
||||
if (!result.ok) throw new Error("fixture invalid");
|
||||
expect(manifestDeclaresCapability(result.manifest, "db.write")).toBe(false);
|
||||
expect(manifestDeclaresCapability(result.manifest, "secret.read")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { z } from "zod";
|
||||
import { PLUGIN_CAPABILITY_NAMES, type PluginCapabilityName } from "./capabilities.js";
|
||||
|
||||
/**
|
||||
* Plugin manifest schema — Phase 06 #21 foundation.
|
||||
*
|
||||
* The manifest is the operator's first line of defence: it declares
|
||||
* which capabilities the plugin requests, which outbound hosts it may
|
||||
* reach, who signed the binary, and which entry-point in the .wasm
|
||||
* file the runtime should invoke for each hook.
|
||||
*
|
||||
* Authors ship one `plugin.toml` (or `plugin.json`) alongside the
|
||||
* `.wasm` binary. The runtime parses it via `parsePluginManifest`,
|
||||
* which returns the typed manifest on success or aggregates Zod
|
||||
* issues on failure. Unknown capabilities are rejected.
|
||||
*/
|
||||
|
||||
// Reverse-DNS-style id: at least two dot-separated labels of [a-z0-9-].
|
||||
// Keeps the registry path tree clean and makes signed-by + manifest
|
||||
// canonicalisation easy. Examples: `com.acme.slack`, `de.toolctrl.hello`.
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/u;
|
||||
|
||||
// Loose SemVer subset: major.minor.patch with optional pre-release tag.
|
||||
// We intentionally do not pull in the `semver` package for the schema
|
||||
// — a regex is enough for input validation; range checks happen later.
|
||||
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/u;
|
||||
|
||||
// Outbound host allowlist entries — bare hostnames, no scheme, no port.
|
||||
// The HTTP host-binding (deferred, in #22) compares against this list.
|
||||
const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/u;
|
||||
const IPV4_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/u;
|
||||
const MAX_MANIFEST_ALLOWED_HOSTS = 64;
|
||||
const MAX_CAPABILITY_SCOPED_HOSTS = 32;
|
||||
const MAX_MANIFEST_CAPABILITIES = 32;
|
||||
const MAX_MANIFEST_HOOKS = 64;
|
||||
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
const SHA256_INTEGRITY_PATTERN = /^sha256-[A-Za-z0-9+/]{43}=$/u;
|
||||
|
||||
function isPublicOutboundHostname(host: string): boolean {
|
||||
const normalized = host.toLowerCase();
|
||||
if (!normalized.includes(".")) return false;
|
||||
if (IPV4_PATTERN.test(normalized)) return false;
|
||||
if (normalized === "localhost" || normalized.endsWith(".localhost")) return false;
|
||||
if (normalized.endsWith(".local") || normalized.endsWith(".internal")) return false;
|
||||
const labels = normalized.split(".");
|
||||
const topLevelLabel = labels.at(-1) ?? "";
|
||||
return !/^\d+$/u.test(topLevelLabel);
|
||||
}
|
||||
|
||||
const HostnameSchema = z.string().regex(HOST_PATTERN).refine(isPublicOutboundHostname, "Must be a public hostname, not localhost, IP, .local, or .internal.");
|
||||
const AllowedHostsSchema = z
|
||||
.array(HostnameSchema)
|
||||
.max(MAX_MANIFEST_ALLOWED_HOSTS)
|
||||
.superRefine((hosts, ctx) => addDuplicateIssue(hosts, ctx, "allowedHosts"));
|
||||
const CapabilityAllowedHostsSchema = z
|
||||
.array(HostnameSchema)
|
||||
.max(MAX_CAPABILITY_SCOPED_HOSTS)
|
||||
.superRefine((hosts, ctx) => addDuplicateIssue(hosts, ctx, "capability scope allowedHosts"));
|
||||
|
||||
const CapabilityScopeSchema = z
|
||||
.object({
|
||||
allowedHosts: CapabilityAllowedHostsSchema.optional(),
|
||||
keyPattern: z.string().min(1).max(128).optional(),
|
||||
tablePrefix: z
|
||||
.string()
|
||||
.regex(/^[a-z][a-z0-9_]*_$/u)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const CapabilitySchema = z
|
||||
.object({
|
||||
name: z.enum(PLUGIN_CAPABILITY_NAMES),
|
||||
scope: CapabilityScopeSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const HookSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128),
|
||||
export: z.string().min(1).max(128),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const RELATIVE_BUNDLE_ENTRY_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?![a-z][a-z0-9+.-]*:)[a-z0-9][a-z0-9._/-]*$/iu;
|
||||
|
||||
const RelativeBundleEntrySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(RELATIVE_BUNDLE_ENTRY_PATTERN, "Must be a relative plugin bundle path without schemes or '..' segments.")
|
||||
.refine((value) => value.toLowerCase().endsWith(".html"), "Settings UI entry must be an HTML file.");
|
||||
|
||||
const PluginUiSchema = z
|
||||
.object({
|
||||
settings: z
|
||||
.object({
|
||||
entry: RelativeBundleEntrySchema,
|
||||
/** Hex-encoded SHA-256 of the settings HTML asset. */
|
||||
sha256: z.string().regex(SHA256_HEX_PATTERN).optional(),
|
||||
/** SRI-compatible SHA-256 integrity token for the settings HTML asset. */
|
||||
integrity: z.string().regex(SHA256_INTEGRITY_PATTERN).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((settings, ctx) => {
|
||||
if (!settings.sha256 && !settings.integrity) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sha256"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (settings.sha256 && settings.integrity) {
|
||||
const integritySha256 = Buffer.from(settings.integrity.slice("sha256-".length), "base64").toString("hex");
|
||||
if (settings.sha256 !== integritySha256) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["integrity"],
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PluginManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
id: z.string().regex(PLUGIN_ID_PATTERN),
|
||||
version: z.string().regex(SEMVER_PATTERN),
|
||||
name: z.string().min(1).max(256),
|
||||
description: z.string().max(2048).optional(),
|
||||
/** Path of the .wasm file relative to the manifest, e.g. "plugin.wasm". */
|
||||
entry: z.string().min(1).max(256),
|
||||
/** Hex-encoded SHA-256 of the binary at the time of signing. */
|
||||
wasmSha256: z.string().regex(SHA256_HEX_PATTERN),
|
||||
/** Hex-encoded Ed25519 signature over the wasm binary. */
|
||||
signature: z.string().regex(/^[0-9a-f]{128}$/u),
|
||||
/** Hex-encoded Ed25519 public key (32 bytes = 64 hex chars). */
|
||||
signedBy: z.string().regex(/^[0-9a-f]{64}$/u),
|
||||
/** Reverse-DNS outbound-HTTP allowlist; empty disables HTTP. */
|
||||
allowedHosts: AllowedHostsSchema.default([]),
|
||||
capabilities: z.array(CapabilitySchema).max(MAX_MANIFEST_CAPABILITIES).default([]),
|
||||
hooks: z.array(HookSchema).min(1).max(MAX_MANIFEST_HOOKS),
|
||||
ui: PluginUiSchema.optional(),
|
||||
/**
|
||||
* Optional host-version compatibility window (Phase 06 #23 Step 12,
|
||||
* F-017). When present, the install path's `isPluginCompatibleWithHost`
|
||||
* gate rejects installs whose host version falls outside
|
||||
* `[minHostVersion, maxHostVersion]`. Both fields are inclusive and
|
||||
* follow the same loose-SemVer regex as `version`. Omitting them
|
||||
* means "any host" — back-compat for plugins authored before the
|
||||
* field was schema-enforced.
|
||||
*/
|
||||
minHostVersion: z.string().regex(SEMVER_PATTERN).optional(),
|
||||
maxHostVersion: z.string().regex(SEMVER_PATTERN).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, ctx) => {
|
||||
addDuplicateIssue(
|
||||
manifest.capabilities.map((capability) => capability.name),
|
||||
ctx,
|
||||
"capability names",
|
||||
["capabilities"]
|
||||
);
|
||||
addDuplicateIssue(
|
||||
manifest.hooks.map((hook) => hook.name),
|
||||
ctx,
|
||||
"hook names",
|
||||
["hooks"]
|
||||
);
|
||||
addDuplicateIssue(
|
||||
manifest.hooks.map((hook) => hook.export),
|
||||
ctx,
|
||||
"hook exports",
|
||||
["hooks"]
|
||||
);
|
||||
});
|
||||
|
||||
function addDuplicateIssue(values: readonly string[], ctx: z.RefinementCtx, label: string, path: (string | number)[] = []): void {
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${label} must be unique.`, path });
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginCapability = z.infer<typeof CapabilitySchema>;
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
export type PluginManifestParseResult = { ok: true; manifest: PluginManifest } | { ok: false; issues: z.ZodIssue[] };
|
||||
|
||||
/**
|
||||
* Parse-or-aggregate. Callers display the issues to the operator; the
|
||||
* runtime never throws synchronously for a bad manifest because a bad
|
||||
* install must not crash the worker process.
|
||||
*/
|
||||
export function parsePluginManifest(input: unknown): PluginManifestParseResult {
|
||||
const result = PluginManifestSchema.safeParse(input);
|
||||
if (result.success) {
|
||||
return { ok: true, manifest: result.data };
|
||||
}
|
||||
return { ok: false, issues: result.error.issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate — does the manifest declare the named capability?
|
||||
* The capability-gateway uses this when wiring host functions.
|
||||
*/
|
||||
export function manifestDeclaresCapability(manifest: PluginManifest, name: PluginCapabilityName): boolean {
|
||||
return manifest.capabilities.some((c) => c.name === name);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
PluginMigrationError,
|
||||
applyPluginMigration,
|
||||
findForbiddenKeyword,
|
||||
sanitizePluginIdForSql,
|
||||
splitStatements,
|
||||
tokenizeSqlIdentifiers,
|
||||
validatePluginMigration,
|
||||
} from "./migrations.js";
|
||||
|
||||
describe("sanitizePluginIdForSql", () => {
|
||||
test("dot-form ids become snake_case", () => {
|
||||
expect(sanitizePluginIdForSql("com.acme.slack")).toBe("com_acme_slack");
|
||||
});
|
||||
test("hyphens are replaced", () => {
|
||||
expect(sanitizePluginIdForSql("de.tool-ctrl.hello")).toBe("de_tool_ctrl_hello");
|
||||
});
|
||||
test("rejects identifiers that do not start with a-z", () => {
|
||||
expect(() => sanitizePluginIdForSql("1.bad.id")).toThrow(/cannot be sanitized/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitStatements", () => {
|
||||
test("strips block + line comments and splits on semicolons", () => {
|
||||
const sql = `
|
||||
-- header comment
|
||||
CREATE TABLE plugin_foo_a (tenantId TEXT NOT NULL);
|
||||
/* block */
|
||||
CREATE INDEX plugin_foo_a_idx ON plugin_foo_a (tenantId);
|
||||
`;
|
||||
const stmts = splitStatements(sql);
|
||||
expect(stmts).toHaveLength(2);
|
||||
expect(stmts[0]).toMatch(/^CREATE TABLE plugin_foo_a/u);
|
||||
expect(stmts[1]).toMatch(/^CREATE INDEX plugin_foo_a_idx/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePluginMigration", () => {
|
||||
test("accepts a simple plugin-prefixed CREATE TABLE with tenantId column", () => {
|
||||
const result = validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "CREATE TABLE plugin_com_acme_slack_messages (id TEXT NOT NULL, tenantId TEXT NOT NULL, body TEXT);",
|
||||
});
|
||||
expect(result.statements).toHaveLength(1);
|
||||
expect(result.allowedPrefix).toBe("plugin_com_acme_slack_");
|
||||
});
|
||||
|
||||
test("rejects CREATE TABLE without prefix", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "CREATE TABLE messages (id TEXT NOT NULL, tenantId TEXT NOT NULL);",
|
||||
})
|
||||
).toThrowError(PluginMigrationError);
|
||||
});
|
||||
|
||||
test("rejects CREATE TABLE missing tenantId column", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "CREATE TABLE plugin_com_acme_slack_data (id TEXT NOT NULL);",
|
||||
})
|
||||
).toThrow(/tenantId/u);
|
||||
});
|
||||
|
||||
test("rejects forbidden tokens", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "DROP DATABASE postgres;",
|
||||
})
|
||||
).toThrow(/forbidden token/u);
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "GRANT ALL ON plugin_com_acme_slack_data TO public;",
|
||||
})
|
||||
).toThrow(/forbidden token/u);
|
||||
});
|
||||
|
||||
test("rejects statements other than CREATE TABLE / CREATE INDEX", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "ALTER TABLE plugin_com_acme_slack_data ADD COLUMN x INT;",
|
||||
})
|
||||
).toThrow(/Only CREATE TABLE/u);
|
||||
});
|
||||
|
||||
test("rejects CREATE INDEX whose table is outside the prefix", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "CREATE INDEX bad_idx ON other_table (id);",
|
||||
})
|
||||
).toThrow(/must start with/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPluginMigration", () => {
|
||||
test("executes each statement via the injected seam", async () => {
|
||||
const executeRawUnsafe = vi.fn().mockResolvedValue(0);
|
||||
const result = await applyPluginMigration(
|
||||
{
|
||||
pluginId: "com.acme.slack",
|
||||
sql:
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (id TEXT NOT NULL, tenantId TEXT NOT NULL);" +
|
||||
"CREATE INDEX plugin_com_acme_slack_messages_idx ON plugin_com_acme_slack_messages (tenantId);",
|
||||
},
|
||||
{ executeRawUnsafe }
|
||||
);
|
||||
expect(executeRawUnsafe).toHaveBeenCalledTimes(2);
|
||||
expect(result.statements).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("wraps execute failure as PluginMigrationError", async () => {
|
||||
const executeRawUnsafe = vi.fn().mockRejectedValue(new Error("relation already exists"));
|
||||
await expect(
|
||||
applyPluginMigration(
|
||||
{
|
||||
pluginId: "com.acme.slack",
|
||||
sql: "CREATE TABLE plugin_com_acme_slack_messages (id TEXT NOT NULL, tenantId TEXT NOT NULL);",
|
||||
},
|
||||
{ executeRawUnsafe }
|
||||
)
|
||||
).rejects.toBeInstanceOf(PluginMigrationError);
|
||||
});
|
||||
|
||||
// F-007 — per-statement audit log before execute.
|
||||
test("emits a per-statement audit info line before each execute (F-007)", async () => {
|
||||
const executeRawUnsafe = vi.fn().mockResolvedValue(0);
|
||||
const infoMock = vi.fn();
|
||||
await applyPluginMigration(
|
||||
{
|
||||
pluginId: "com.acme.slack",
|
||||
sql:
|
||||
"CREATE TABLE plugin_com_acme_slack_a (id TEXT NOT NULL, tenantId TEXT NOT NULL);" +
|
||||
"CREATE TABLE plugin_com_acme_slack_b (id TEXT NOT NULL, tenantId TEXT NOT NULL);",
|
||||
},
|
||||
{ executeRawUnsafe, logger: { info: infoMock, error: vi.fn() } }
|
||||
);
|
||||
const perStatementCalls = infoMock.mock.calls.filter(
|
||||
(call) => call[1] === "plugin.migration.execute_statement"
|
||||
);
|
||||
expect(perStatementCalls).toHaveLength(2);
|
||||
expect(perStatementCalls[0]![0]).toMatchObject({
|
||||
pluginId: "com.acme.slack",
|
||||
statementIndex: 0,
|
||||
statementCount: 2,
|
||||
});
|
||||
expect(perStatementCalls[1]![0]).toMatchObject({ statementIndex: 1 });
|
||||
// statementPreview is bounded to 200 chars.
|
||||
for (const call of perStatementCalls) {
|
||||
const preview = (call[0] as { statementPreview: string }).statementPreview;
|
||||
expect(preview.length).toBeLessThanOrEqual(200);
|
||||
}
|
||||
});
|
||||
|
||||
test("execute_failed log line includes the statement index (F-007)", async () => {
|
||||
const executeRawUnsafe = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const errorMock = vi.fn();
|
||||
await expect(
|
||||
applyPluginMigration(
|
||||
{
|
||||
pluginId: "com.acme.slack",
|
||||
sql:
|
||||
"CREATE TABLE plugin_com_acme_slack_a (id TEXT NOT NULL, tenantId TEXT NOT NULL);",
|
||||
},
|
||||
{ executeRawUnsafe, logger: { info: vi.fn(), error: errorMock } }
|
||||
)
|
||||
).rejects.toBeInstanceOf(PluginMigrationError);
|
||||
expect(errorMock).toHaveBeenCalled();
|
||||
expect(errorMock.mock.calls[0]![0]).toMatchObject({
|
||||
pluginId: "com.acme.slack",
|
||||
statementIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// F-021 — strict tenantId column check.
|
||||
describe("strict tenantId column check (F-021)", () => {
|
||||
test("rejects a table that only mentions tenantId inside a DEFAULT literal", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (" +
|
||||
"id TEXT NOT NULL, " +
|
||||
"label TEXT DEFAULT 'has tenantId in the value but no real column'" +
|
||||
")";
|
||||
expect(() => validatePluginMigration({ pluginId: "com.acme.slack", sql })).toThrow(
|
||||
/tenantId column/
|
||||
);
|
||||
try {
|
||||
validatePluginMigration({ pluginId: "com.acme.slack", sql });
|
||||
} catch (err) {
|
||||
expect((err as PluginMigrationError).code).toBe("missing_tenant_id_column");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a table where tenantId is the first column", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (" +
|
||||
"tenantId TEXT NOT NULL, id TEXT NOT NULL" +
|
||||
")";
|
||||
const result = validatePluginMigration({ pluginId: "com.acme.slack", sql });
|
||||
expect(result.statements).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("accepts a table where tenantId is a later column", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (" +
|
||||
"id TEXT NOT NULL, tenantId TEXT NOT NULL, body TEXT" +
|
||||
")";
|
||||
const result = validatePluginMigration({ pluginId: "com.acme.slack", sql });
|
||||
expect(result.statements).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("accepts a table with a quoted tenantId column name", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (" +
|
||||
`id TEXT NOT NULL, "tenantId" TEXT NOT NULL` +
|
||||
")";
|
||||
const result = validatePluginMigration({ pluginId: "com.acme.slack", sql });
|
||||
expect(result.statements).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("still rejects when neither a column nor a default mentions tenantId", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_messages (" +
|
||||
"id TEXT NOT NULL, name TEXT NOT NULL" +
|
||||
")";
|
||||
expect(() => validatePluginMigration({ pluginId: "com.acme.slack", sql })).toThrow(
|
||||
/tenantId column/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// F-008 — statement-count cap.
|
||||
describe("statement-count cap (F-008)", () => {
|
||||
test("rejects migrations declaring more than DEFAULT_MAX_STATEMENTS_PER_MIGRATION (32) statements", () => {
|
||||
const stmts: string[] = [];
|
||||
for (let i = 0; i < 33; i++) {
|
||||
stmts.push(`CREATE TABLE plugin_com_acme_slack_t${i} (id TEXT NOT NULL, tenantId TEXT NOT NULL)`);
|
||||
}
|
||||
const sql = stmts.join(";") + ";";
|
||||
expect(() => validatePluginMigration({ pluginId: "com.acme.slack", sql })).toThrow(
|
||||
PluginMigrationError
|
||||
);
|
||||
try {
|
||||
validatePluginMigration({ pluginId: "com.acme.slack", sql });
|
||||
} catch (err) {
|
||||
expect((err as PluginMigrationError).code).toBe("too_many_statements");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts migrations exactly at the default cap", () => {
|
||||
const stmts: string[] = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
stmts.push(`CREATE TABLE plugin_com_acme_slack_t${i} (id TEXT NOT NULL, tenantId TEXT NOT NULL)`);
|
||||
}
|
||||
const result = validatePluginMigration({
|
||||
pluginId: "com.acme.slack",
|
||||
sql: stmts.join(";") + ";",
|
||||
});
|
||||
expect(result.statements).toHaveLength(32);
|
||||
});
|
||||
|
||||
test("honours a per-call maxStatements override", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_com_acme_slack_a (id TEXT NOT NULL, tenantId TEXT NOT NULL);" +
|
||||
"CREATE TABLE plugin_com_acme_slack_b (id TEXT NOT NULL, tenantId TEXT NOT NULL);" +
|
||||
"CREATE TABLE plugin_com_acme_slack_c (id TEXT NOT NULL, tenantId TEXT NOT NULL);";
|
||||
expect(() =>
|
||||
validatePluginMigration({ pluginId: "com.acme.slack", sql, maxStatements: 2 })
|
||||
).toThrow(/cap is 2/);
|
||||
});
|
||||
});
|
||||
|
||||
// F-006 — AST-lite tokenizer behaviour.
|
||||
describe("tokenizer-based forbidden-keyword scan (F-006)", () => {
|
||||
test("tokenizeSqlIdentifiers skips content inside single-quoted strings", () => {
|
||||
const tokens = tokenizeSqlIdentifiers("CREATE TABLE t (label TEXT DEFAULT 'no grant inside')");
|
||||
const upper = tokens.map((t) => t.text);
|
||||
expect(upper).toContain("CREATE");
|
||||
expect(upper).toContain("TABLE");
|
||||
expect(upper).not.toContain("GRANT");
|
||||
});
|
||||
|
||||
test("tokenizeSqlIdentifiers skips content inside dollar-quoted strings", () => {
|
||||
const tokens = tokenizeSqlIdentifiers("CREATE TABLE t (x TEXT DEFAULT $$GRANT something$$)");
|
||||
expect(tokens.map((t) => t.text)).not.toContain("GRANT");
|
||||
});
|
||||
|
||||
test("tokenizeSqlIdentifiers does descend into block comments / line comments", () => {
|
||||
const tokens = tokenizeSqlIdentifiers("CREATE TABLE t /* GRANT */ (id TEXT)");
|
||||
expect(tokens.map((t) => t.text)).not.toContain("GRANT");
|
||||
const tokens2 = tokenizeSqlIdentifiers("CREATE TABLE t (id TEXT) -- GRANT trick\n");
|
||||
expect(tokens2.map((t) => t.text)).not.toContain("GRANT");
|
||||
});
|
||||
|
||||
test("findForbiddenKeyword catches DROP DATABASE / GRANT / REVOKE / pg_*", () => {
|
||||
expect(findForbiddenKeyword(tokenizeSqlIdentifiers("DROP DATABASE x"))?.keyword).toBe("DROP DATABASE");
|
||||
expect(findForbiddenKeyword(tokenizeSqlIdentifiers("REVOKE all on t from u"))?.keyword).toBe("REVOKE");
|
||||
expect(findForbiddenKeyword(tokenizeSqlIdentifiers("SELECT pg_terminate_backend(1)"))?.keyword).toBe("PG_*");
|
||||
});
|
||||
|
||||
test("findForbiddenKeyword returns null when GRANT only appears inside a string literal", () => {
|
||||
const tokens = tokenizeSqlIdentifiers(
|
||||
"CREATE TABLE plugin_demo_x (id TEXT, tenantId TEXT, note TEXT DEFAULT 'GRANT pattern in literal')",
|
||||
);
|
||||
expect(findForbiddenKeyword(tokens)).toBeNull();
|
||||
});
|
||||
|
||||
test("validatePluginMigration no longer false-positives when GRANT appears only in a DEFAULT literal", () => {
|
||||
const sql =
|
||||
"CREATE TABLE plugin_demo_x_notes (id TEXT NOT NULL, tenantId TEXT NOT NULL, " +
|
||||
"note TEXT NOT NULL DEFAULT 'mentions GRANT in the literal')";
|
||||
const result = validatePluginMigration({ pluginId: "demo.x", sql });
|
||||
expect(result.statements).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("validatePluginMigration still rejects a real GRANT statement", () => {
|
||||
expect(() =>
|
||||
validatePluginMigration({
|
||||
pluginId: "demo.x",
|
||||
sql: "GRANT ALL ON ALL TABLES IN SCHEMA public TO postgres",
|
||||
}),
|
||||
).toThrow(/forbidden token/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Phase 06 #22 — Plugin-DB migration applier.
|
||||
*
|
||||
* Plugins that need their own tables declare them in the manifest
|
||||
* (foundation: single `migration.up` SQL string). The operator-side
|
||||
* lifecycle service calls {@link applyPluginMigration} on install,
|
||||
* which:
|
||||
*
|
||||
* 1. Validates the SQL text only references tables prefixed with
|
||||
* `plugin_<sanitized-plugin-id>_` (no DROP DATABASE, no GRANT,
|
||||
* no cross-schema writes — the manifest cannot trick the host
|
||||
* into running arbitrary DDL).
|
||||
* 2. Executes the (possibly multi-statement) SQL via the injected
|
||||
* `executeRawUnsafe` seam — supplied at call-site as Prisma
|
||||
* `$executeRawUnsafe`.
|
||||
* 3. Records the applied version so re-installs are idempotent.
|
||||
*
|
||||
* Down-migrations + per-version sequencing are intentionally deferred:
|
||||
* the foundation accepts one up-migration per install. Subsequent
|
||||
* versions can re-run with their own up-SQL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitize a reverse-DNS plugin id (`com.acme.slack`) into a SQL
|
||||
* identifier (`com_acme_slack`). Lowercases, replaces `.` and `-`
|
||||
* with `_`, then rejects anything outside `[a-z0-9_]`.
|
||||
*/
|
||||
export function sanitizePluginIdForSql(pluginId: string): string {
|
||||
const lower = pluginId.toLowerCase();
|
||||
const replaced = lower.replace(/[.\-]/g, "_");
|
||||
if (!/^[a-z][a-z0-9_]*$/u.test(replaced)) {
|
||||
throw new PluginMigrationError(
|
||||
"invalid_plugin_id",
|
||||
`Plugin id '${pluginId}' cannot be sanitized to a SQL identifier.`
|
||||
);
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
export class PluginMigrationError extends Error {
|
||||
public override readonly name = "PluginMigrationError";
|
||||
public readonly code:
|
||||
| "invalid_plugin_id"
|
||||
| "forbidden_statement"
|
||||
| "prefix_violation"
|
||||
| "missing_tenant_id_column"
|
||||
| "too_many_statements"
|
||||
| "execute_failed";
|
||||
constructor(code: PluginMigrationError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard cap on the number of statements a single plugin migration may
|
||||
* declare. Defends against the "thousand-tables" DoS where a manifest
|
||||
* spans the entire DB with garbage. Closes F-008 from the internal
|
||||
* findings. Operators can override via the migration runner caller; the
|
||||
* default is conservative because legitimate plugin migrations rarely
|
||||
* exceed a handful of tables.
|
||||
*/
|
||||
export const DEFAULT_MAX_STATEMENTS_PER_MIGRATION = 32;
|
||||
|
||||
/**
|
||||
* Comment / whitespace stripper used by both the statement splitter
|
||||
* and the keyword scanner. Removes:
|
||||
* - `-- line comments`
|
||||
* - `/* block comments */`
|
||||
* and collapses runs of whitespace.
|
||||
*/
|
||||
export function stripSqlNoise(sql: string): string {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//gu, " ")
|
||||
.replace(/--[^\n]*/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const FORBIDDEN_TOKENS = [
|
||||
"DROP DATABASE",
|
||||
"DROP SCHEMA",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"CREATE USER",
|
||||
"ALTER USER",
|
||||
"DROP USER",
|
||||
"COPY ",
|
||||
"TRUNCATE",
|
||||
"SET ROLE",
|
||||
"PG_",
|
||||
];
|
||||
|
||||
/**
|
||||
* F-006 — defence-in-depth AST-lite token scan.
|
||||
*
|
||||
* Stage-1 forbidden-tokens check is a substring match on the stripped
|
||||
* SQL — robust against simple split-comment-grant obfuscation (the comment is
|
||||
* removed first) but vulnerable to two failure modes:
|
||||
* - false positives when a string literal contains a forbidden word
|
||||
* (a column DEFAULT with the substring "grant" trips the rule);
|
||||
*
|
||||
* Stage-2 (this function) walks the SQL token-by-token respecting:
|
||||
* - single-quoted strings with two single quotes escape
|
||||
* - double-quoted identifiers with two double quotes escape
|
||||
* - dollar-quoted strings ($$ ... $$ and $tag$ ... $tag$)
|
||||
* - the actual unicode whitespace class
|
||||
* It returns the sequence of identifier-shaped tokens (uppercased). The
|
||||
* caller scans this sequence for forbidden keyword pairs without
|
||||
* worrying about content inside literals.
|
||||
*
|
||||
* Intentionally tiny — we only need enough of a tokenizer to make
|
||||
* obfuscation harder, not to parse arbitrary Postgres.
|
||||
*/
|
||||
export type SqlIdentifierToken = {
|
||||
readonly text: string; // uppercased
|
||||
readonly start: number;
|
||||
};
|
||||
|
||||
export function tokenizeSqlIdentifiers(sql: string): ReadonlyArray<SqlIdentifierToken> {
|
||||
const tokens: SqlIdentifierToken[] = [];
|
||||
const len = sql.length;
|
||||
let i = 0;
|
||||
while (i < len) {
|
||||
const ch = sql[i]!;
|
||||
// line comment
|
||||
if (ch === "-" && sql[i + 1] === "-") {
|
||||
while (i < len && sql[i] !== "\n") i += 1;
|
||||
continue;
|
||||
}
|
||||
// block comment
|
||||
if (ch === "/" && sql[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < len && !(sql[i] === "*" && sql[i + 1] === "/")) i += 1;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// single-quoted string
|
||||
if (ch === "'") {
|
||||
i += 1;
|
||||
while (i < len) {
|
||||
if (sql[i] === "'" && sql[i + 1] === "'") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[i] === "'") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// double-quoted identifier
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i += 1;
|
||||
let inner = "";
|
||||
while (i < len) {
|
||||
if (sql[i] === '"' && sql[i + 1] === '"') {
|
||||
inner += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (sql[i] === '"') {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
inner += sql[i]!;
|
||||
i += 1;
|
||||
}
|
||||
tokens.push({ text: inner.toUpperCase(), start });
|
||||
continue;
|
||||
}
|
||||
// dollar-quoted string ($tag$ ... $tag$)
|
||||
if (ch === "$") {
|
||||
const tagMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
|
||||
if (tagMatch) {
|
||||
const closeTag = tagMatch[0];
|
||||
i += closeTag.length;
|
||||
const close = sql.indexOf(closeTag, i);
|
||||
i = close === -1 ? len : close + closeTag.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// identifier
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
const start = i;
|
||||
let end = i + 1;
|
||||
while (end < len && /[A-Za-z0-9_]/.test(sql[end]!)) end += 1;
|
||||
tokens.push({ text: sql.slice(start, end).toUpperCase(), start });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the tokenized identifier stream for any forbidden 1- or 2-word
|
||||
* keyword sequence. Returns the first match or null. Unlike the stage-1
|
||||
* substring scan, this respects string-literal content (so a legitimate
|
||||
* `DEFAULT 'no grant'` no longer false-positives) AND tightens the
|
||||
* obfuscation surface (string-internal forbidden words cannot smuggle
|
||||
* a real GRANT past the splitter).
|
||||
*/
|
||||
export function findForbiddenKeyword(
|
||||
tokens: ReadonlyArray<SqlIdentifierToken>,
|
||||
): { keyword: string; position: number } | null {
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i]!.text;
|
||||
const next = tokens[i + 1]?.text ?? "";
|
||||
// 2-word keywords
|
||||
const pair = `${t} ${next}`;
|
||||
if (
|
||||
pair === "DROP DATABASE" ||
|
||||
pair === "DROP SCHEMA" ||
|
||||
pair === "CREATE USER" ||
|
||||
pair === "ALTER USER" ||
|
||||
pair === "DROP USER" ||
|
||||
pair === "SET ROLE"
|
||||
) {
|
||||
return { keyword: pair, position: tokens[i]!.start };
|
||||
}
|
||||
// single-word keywords
|
||||
if (
|
||||
t === "GRANT" ||
|
||||
t === "REVOKE" ||
|
||||
t === "TRUNCATE" ||
|
||||
t === "COPY"
|
||||
) {
|
||||
return { keyword: t, position: tokens[i]!.start };
|
||||
}
|
||||
// pg_* function family
|
||||
if (t.startsWith("PG_")) {
|
||||
return { keyword: "PG_*", position: tokens[i]!.start };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a CREATE TABLE statement targets the allowed prefix
|
||||
* and includes a `tenantId` column. We deliberately keep the parser
|
||||
* dumb — anything that does not match the simple "CREATE TABLE
|
||||
* <name> ( ... )" shape is rejected. Plugin authors who want fancy
|
||||
* migrations should split them into multiple CREATE TABLEs.
|
||||
*/
|
||||
function validateCreateTable(statement: string, allowedPrefix: string): void {
|
||||
const m = /^CREATE\s+TABLE(\s+IF\s+NOT\s+EXISTS)?\s+("?)([a-z_][a-z0-9_]*)\2\s*\(([\s\S]+)\)\s*$/iu.exec(statement);
|
||||
if (!m) {
|
||||
throw new PluginMigrationError(
|
||||
"forbidden_statement",
|
||||
`CREATE TABLE statement does not match the supported shape: ${statement.slice(0, 80)}…`
|
||||
);
|
||||
}
|
||||
const tableName = m[3]!;
|
||||
if (!tableName.startsWith(allowedPrefix)) {
|
||||
throw new PluginMigrationError(
|
||||
"prefix_violation",
|
||||
`Table '${tableName}' must start with '${allowedPrefix}'.`
|
||||
);
|
||||
}
|
||||
const body = m[4]!;
|
||||
// F-021 — strict column-name check. The previous `\btenantId\b` regex
|
||||
// matched the substring anywhere in the body, including inside DEFAULT
|
||||
// literals like `comment TEXT DEFAULT 'tenantId here'`. We now require
|
||||
// `tenantId` to appear as a column-definition header: preceded by
|
||||
// either the start of the body or a comma (the column-def separator),
|
||||
// optionally double-quoted, followed by whitespace before the type.
|
||||
// Comments are already removed by stripSqlNoise before we get here.
|
||||
if (!/(?:^|,)\s*"?tenantId"?\s+/iu.test(body)) {
|
||||
throw new PluginMigrationError(
|
||||
"missing_tenant_id_column",
|
||||
`Table '${tableName}' must declare a tenantId column for tenant scoping.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCreateIndex(statement: string, allowedPrefix: string): void {
|
||||
const m = /^CREATE\s+(UNIQUE\s+)?INDEX(\s+IF\s+NOT\s+EXISTS)?\s+("?)([a-z_][a-z0-9_]*)\3\s+ON\s+("?)([a-z_][a-z0-9_]*)\5/iu.exec(statement);
|
||||
if (!m) {
|
||||
throw new PluginMigrationError(
|
||||
"forbidden_statement",
|
||||
`CREATE INDEX statement does not match the supported shape: ${statement.slice(0, 80)}…`
|
||||
);
|
||||
}
|
||||
const tableName = m[6]!;
|
||||
if (!tableName.startsWith(allowedPrefix)) {
|
||||
throw new PluginMigrationError(
|
||||
"prefix_violation",
|
||||
`Index target '${tableName}' must start with '${allowedPrefix}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split on top-level semicolons. Keeps it intentionally naive — the
|
||||
* supported migration grammar (CREATE TABLE / CREATE INDEX) does not
|
||||
* embed semicolons inside string literals.
|
||||
*/
|
||||
export function splitStatements(sql: string): ReadonlyArray<string> {
|
||||
return stripSqlNoise(sql)
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
export type ValidatedPluginMigration = {
|
||||
pluginId: string;
|
||||
sanitizedPluginId: string;
|
||||
allowedPrefix: string;
|
||||
statements: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse + validate a plugin migration SQL blob without executing it.
|
||||
* Throws {@link PluginMigrationError} on any rule violation.
|
||||
*/
|
||||
export function validatePluginMigration(args: {
|
||||
pluginId: string;
|
||||
sql: string;
|
||||
/** Override the default DEFAULT_MAX_STATEMENTS_PER_MIGRATION cap. */
|
||||
maxStatements?: number;
|
||||
}): ValidatedPluginMigration {
|
||||
const sanitized = sanitizePluginIdForSql(args.pluginId);
|
||||
const allowedPrefix = `plugin_${sanitized}_`;
|
||||
const statements = splitStatements(args.sql);
|
||||
const maxStatements = args.maxStatements ?? DEFAULT_MAX_STATEMENTS_PER_MIGRATION;
|
||||
if (statements.length > maxStatements) {
|
||||
throw new PluginMigrationError(
|
||||
"too_many_statements",
|
||||
`Migration declares ${statements.length} statements; cap is ${maxStatements}. ` +
|
||||
"Split into multiple plugin versions or raise PLUGIN_MAX_STATEMENTS_PER_MIGRATION.",
|
||||
);
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
const upper = stmt.toUpperCase();
|
||||
// Stage 1 (legacy substring scan) — kept as belt-and-braces. Stage 2
|
||||
// (token-aware scan, F-006) catches obfuscation + ignores string
|
||||
// literal content. We run both: if stage 1 rejects a string-literal
|
||||
// false-positive (e.g. `DEFAULT 'no grant'`) we let stage 2 override
|
||||
// by checking it first. If stage 2 finds nothing AND stage 1 finds
|
||||
// something, that something is by definition inside a string literal
|
||||
// or a comment — both are benign in our limited grammar.
|
||||
const tokens = tokenizeSqlIdentifiers(stmt);
|
||||
const stage2 = findForbiddenKeyword(tokens);
|
||||
if (stage2) {
|
||||
throw new PluginMigrationError(
|
||||
"forbidden_statement",
|
||||
`Statement contains forbidden token '${stage2.keyword}' at position ${stage2.position}: ${stmt.slice(0, 80)}…`,
|
||||
);
|
||||
}
|
||||
// Stage 1 fallback only when stage 2 found nothing — guards against
|
||||
// a tokenizer edge-case missing something the substring scan would
|
||||
// catch. Skip the check when the only stage-1 hit is inside a
|
||||
// string literal (already cleared by stage 2's literal-aware walk).
|
||||
for (const forbidden of FORBIDDEN_TOKENS) {
|
||||
if (upper.includes(forbidden)) {
|
||||
// Only treat as a real reject if NO identifier token containing
|
||||
// the forbidden text exists OUTSIDE a string literal. The
|
||||
// tokenizer already skipped string content; if a substring match
|
||||
// wasn't covered by stage 2, it lives inside a literal/comment.
|
||||
const live = tokens.some((tk) => tk.text.includes(forbidden.trim()));
|
||||
if (live) {
|
||||
throw new PluginMigrationError(
|
||||
"forbidden_statement",
|
||||
`Statement contains forbidden token '${forbidden}': ${stmt.slice(0, 80)}…`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (upper.startsWith("CREATE TABLE")) {
|
||||
validateCreateTable(stmt, allowedPrefix);
|
||||
} else if (upper.startsWith("CREATE INDEX") || upper.startsWith("CREATE UNIQUE INDEX")) {
|
||||
validateCreateIndex(stmt, allowedPrefix);
|
||||
} else {
|
||||
throw new PluginMigrationError(
|
||||
"forbidden_statement",
|
||||
`Only CREATE TABLE / CREATE INDEX are allowed for plugin migrations. Got: ${stmt.slice(0, 40)}…`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { pluginId: args.pluginId, sanitizedPluginId: sanitized, allowedPrefix, statements };
|
||||
}
|
||||
|
||||
export type ApplyPluginMigrationDeps = {
|
||||
/** Prisma `$executeRawUnsafe`-shaped seam. Returns rows affected. */
|
||||
executeRawUnsafe: (sql: string) => Promise<number>;
|
||||
logger?: {
|
||||
info: (fields: Record<string, unknown>, msg: string) => void;
|
||||
warn?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
error?: (fields: Record<string, unknown>, msg: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the SQL, then execute each statement sequentially through
|
||||
* the injected seam. Returns the validated migration record for the
|
||||
* caller to persist (e.g. `plugin_migrations_applied`).
|
||||
*/
|
||||
export async function applyPluginMigration(
|
||||
args: { pluginId: string; sql: string },
|
||||
deps: ApplyPluginMigrationDeps
|
||||
): Promise<ValidatedPluginMigration> {
|
||||
const validated = validatePluginMigration(args);
|
||||
// F-007 — emit a per-statement audit line BEFORE running, so even a
|
||||
// crash mid-execution leaves a trail of what the host attempted.
|
||||
// The line is structured so log aggregation can join it back to the
|
||||
// pluginId. We deliberately do NOT log the full statement — only the
|
||||
// first 200 chars (enough for forensic correlation, short enough to
|
||||
// bound log volume for big migrations).
|
||||
for (let i = 0; i < validated.statements.length; i++) {
|
||||
const stmt = validated.statements[i]!;
|
||||
deps.logger?.info?.(
|
||||
{
|
||||
pluginId: args.pluginId,
|
||||
statementIndex: i,
|
||||
statementCount: validated.statements.length,
|
||||
statementPreview: stmt.slice(0, 200),
|
||||
},
|
||||
"plugin.migration.execute_statement"
|
||||
);
|
||||
try {
|
||||
await deps.executeRawUnsafe(stmt);
|
||||
} catch (err) {
|
||||
deps.logger?.error?.(
|
||||
{
|
||||
pluginId: args.pluginId,
|
||||
statementIndex: i,
|
||||
statementPreview: stmt.slice(0, 200),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin.migration.execute_failed"
|
||||
);
|
||||
throw new PluginMigrationError(
|
||||
"execute_failed",
|
||||
`Plugin migration for ${args.pluginId} failed to execute: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
deps.logger?.info?.(
|
||||
{ pluginId: args.pluginId, statements: validated.statements.length },
|
||||
"plugin.migration.applied"
|
||||
);
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
describe("plugin runtime package exports", () => {
|
||||
test("exposes browser-safe subpaths without forcing the node runtime aggregate into web bundles", () => {
|
||||
expect(packageJson.exports).toMatchObject({
|
||||
"./capabilities": "./src/capabilities.ts",
|
||||
"./hooks/registry": "./src/hooks/registry.ts",
|
||||
"./manifest": "./src/manifest.ts",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { assertPluginRuntimeProductionEnabled, describePluginRuntimeProductionGate, isPluginRuntimeProductionEnabled } from "./production-gate.js";
|
||||
|
||||
describe("plugin runtime production gate", () => {
|
||||
test("allows non-production environments without the rollout flag", () => {
|
||||
expect(isPluginRuntimeProductionEnabled({ NODE_ENV: "test" })).toBe(true);
|
||||
expect(describePluginRuntimeProductionGate({ NODE_ENV: "development" })).toBeNull();
|
||||
});
|
||||
|
||||
test("requires explicit operator enablement in production", () => {
|
||||
const env = { NODE_ENV: "production" };
|
||||
|
||||
expect(isPluginRuntimeProductionEnabled(env)).toBe(false);
|
||||
expect(describePluginRuntimeProductionGate(env)).toContain("PLUGIN_RUNTIME_PRODUCTION_ENABLED=true");
|
||||
expect(() => assertPluginRuntimeProductionEnabled(env)).toThrow("PLUGIN_RUNTIME_PRODUCTION_ENABLED=true");
|
||||
});
|
||||
|
||||
test("allows production only when explicitly enabled", () => {
|
||||
expect(isPluginRuntimeProductionEnabled({ NODE_ENV: "production", PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export type PluginRuntimeEnv = {
|
||||
[key: string]: string | undefined;
|
||||
NODE_ENV?: string;
|
||||
PLUGIN_RUNTIME_PRODUCTION_ENABLED?: string;
|
||||
};
|
||||
|
||||
export const PLUGIN_RUNTIME_PRODUCTION_GATE_ENV = "PLUGIN_RUNTIME_PRODUCTION_ENABLED";
|
||||
|
||||
export function isPluginRuntimeProductionEnabled(env: PluginRuntimeEnv = process.env): boolean {
|
||||
return env.NODE_ENV !== "production" || env.PLUGIN_RUNTIME_PRODUCTION_ENABLED === "true";
|
||||
}
|
||||
|
||||
export function describePluginRuntimeProductionGate(env: PluginRuntimeEnv = process.env): string | null {
|
||||
if (isPluginRuntimeProductionEnabled(env)) {
|
||||
return null;
|
||||
}
|
||||
return `${PLUGIN_RUNTIME_PRODUCTION_GATE_ENV}=true is required before tenant plugin runtime operations are allowed in production.`;
|
||||
}
|
||||
|
||||
export function assertPluginRuntimeProductionEnabled(env: PluginRuntimeEnv = process.env): void {
|
||||
const reason = describePluginRuntimeProductionGate(env);
|
||||
if (reason) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createPluginSandbox } from "./runtime.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
/**
|
||||
* Minimal hand-assembled WASM module exporting `double(i32) -> i32`
|
||||
* and `unreachable()` that traps. Keeping the bytes inline avoids a
|
||||
* filesystem dependency in unit tests.
|
||||
*
|
||||
* WAT equivalent:
|
||||
* (module
|
||||
* (func (export "double") (param i32) (result i32)
|
||||
* local.get 0 i32.const 2 i32.mul)
|
||||
* (func (export "boom")
|
||||
* unreachable))
|
||||
*/
|
||||
const HELLO_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
// type section: id=0x01, size=0x09, count=2, type0=(i32)->i32, type1=()->()
|
||||
0x01, 0x09, 0x02, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x60, 0x00, 0x00,
|
||||
// function section: id=0x03, size=0x03, count=2, func0 uses type0, func1 uses type1
|
||||
0x03, 0x03, 0x02, 0x00, 0x01,
|
||||
// export section: id=0x07, size=0x11, count=2
|
||||
// export "double" -> func 0; export "boom" -> func 1
|
||||
0x07, 0x11, 0x02,
|
||||
0x06, 0x64, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x00, 0x00,
|
||||
0x04, 0x62, 0x6f, 0x6f, 0x6d, 0x00, 0x01,
|
||||
// code section: id=0x0a, size=0x0d, count=2
|
||||
// func0: size=7, 0 locals, body = local.get 0; i32.const 2; i32.mul; end
|
||||
// func1: size=3, 0 locals, body = unreachable; end
|
||||
0x0a, 0x0d, 0x02,
|
||||
0x07, 0x00, 0x20, 0x00, 0x41, 0x02, 0x6c, 0x0b,
|
||||
0x03, 0x00, 0x00, 0x0b,
|
||||
]);
|
||||
|
||||
const PAYLOAD_IMPORT_WASM = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
// type section: () -> i32, (i32) -> i32
|
||||
0x01, 0x0a, 0x02, 0x60, 0x00, 0x01, 0x7f, 0x60, 0x01, 0x7f, 0x01, 0x7f,
|
||||
// import section: env.toolctrl_payload_len, env.toolctrl_payload_byte
|
||||
0x02, 0x38, 0x02,
|
||||
0x03, 0x65, 0x6e, 0x76,
|
||||
0x14, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6c, 0x65, 0x6e,
|
||||
0x00, 0x00,
|
||||
0x03, 0x65, 0x6e, 0x76,
|
||||
0x15, 0x74, 0x6f, 0x6f, 0x6c, 0x63, 0x74, 0x72, 0x6c, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65,
|
||||
0x00, 0x01,
|
||||
// function section: two local functions, both () -> i32
|
||||
0x03, 0x03, 0x02, 0x00, 0x00,
|
||||
// export section: payload_len and payload_first
|
||||
0x07, 0x1f, 0x02,
|
||||
0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6c, 0x65, 0x6e, 0x00, 0x02,
|
||||
0x0d, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x66, 0x69, 0x72, 0x73, 0x74, 0x00, 0x03,
|
||||
// code section
|
||||
0x0a, 0x0d, 0x02,
|
||||
0x04, 0x00, 0x10, 0x00, 0x0b,
|
||||
0x06, 0x00, 0x41, 0x00, 0x10, 0x01, 0x0b,
|
||||
]);
|
||||
|
||||
const FIXTURE_MANIFEST: PluginManifest = {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks: [{ name: "double", export: "double" }],
|
||||
};
|
||||
|
||||
describe("createPluginSandbox", () => {
|
||||
test("instantiates and invokes a numeric export", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
memoryLimitMb: 1,
|
||||
fuelLimit: 100,
|
||||
});
|
||||
const result = await sandbox.invoke("double", 21);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.output).toBe(42);
|
||||
sandbox.close();
|
||||
});
|
||||
|
||||
test("reports hook_not_exported for unknown hook names", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
});
|
||||
const result = await sandbox.invoke("missing", 0);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("hook_not_exported");
|
||||
});
|
||||
|
||||
test("classifies unreachable traps as 'trap'", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
});
|
||||
const result = await sandbox.invoke("boom", 0);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("trap");
|
||||
});
|
||||
|
||||
test("returns compile_failed for invalid wasm bytes", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
});
|
||||
const result = await sandbox.invoke("double", 1);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("compile_failed");
|
||||
});
|
||||
|
||||
test("rejects non-positive memory and fuel limits", () => {
|
||||
expect(() =>
|
||||
createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
memoryLimitMb: 0,
|
||||
})
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
fuelLimit: 0,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("close() makes subsequent invokes report trap with 'sandbox closed'", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
});
|
||||
sandbox.close();
|
||||
const result = await sandbox.invoke("double", 2);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe("trap");
|
||||
expect(result.message).toMatch(/closed/);
|
||||
}
|
||||
});
|
||||
|
||||
test("fuelRemaining decrements on every successful call", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: HELLO_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
fuelLimit: 5,
|
||||
});
|
||||
expect(sandbox.fuelRemaining()).toBe(5);
|
||||
await sandbox.invoke("double", 1);
|
||||
expect(sandbox.fuelRemaining()).toBe(4);
|
||||
});
|
||||
|
||||
test("exposes structured payloads through the JSON payload host imports", async () => {
|
||||
const sandbox = createPluginSandbox({
|
||||
wasmBuffer: PAYLOAD_IMPORT_WASM,
|
||||
manifest: FIXTURE_MANIFEST,
|
||||
fuelLimit: 5,
|
||||
});
|
||||
|
||||
const lenResult = await sandbox.invoke("payload_len", { ok: true });
|
||||
expect(lenResult.ok).toBe(true);
|
||||
if (lenResult.ok) expect(lenResult.output).toBe(JSON.stringify({ ok: true }).length);
|
||||
|
||||
const firstByteResult = await sandbox.invoke("payload_first", { ok: true });
|
||||
expect(firstByteResult.ok).toBe(true);
|
||||
if (firstByteResult.ok) expect(firstByteResult.output).toBe("{".charCodeAt(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import { TextEncoder } from "node:util";
|
||||
|
||||
// Minimal ambient WebAssembly typings — the package targets Node 22+
|
||||
// where the global is present at runtime, but TS lib "ES2022" alone does
|
||||
// not declare the namespace. Re-declaring locally keeps the package
|
||||
// self-contained (consumers don't need lib: "DOM"). Cast through unknown
|
||||
// is intentional: the shapes are widened to whatever V8 exposes.
|
||||
type WasmModule = unknown;
|
||||
type WasmInstance = { readonly exports: Record<string, unknown> };
|
||||
type WasmImports = Record<string, Record<string, unknown>>;
|
||||
declare const WebAssembly: {
|
||||
Module: new (bytes: BufferSource) => WasmModule;
|
||||
Instance: new (mod: WasmModule, imports?: WasmImports) => WasmInstance;
|
||||
Memory: new (descriptor: { initial: number; maximum?: number }) => unknown;
|
||||
};
|
||||
// `BufferSource` is a DOM type — alias to ArrayBufferView | ArrayBuffer.
|
||||
type BufferSource = ArrayBufferView | ArrayBuffer;
|
||||
|
||||
/**
|
||||
* WebAssembly sandbox wrapper — Phase 06 #21 foundation.
|
||||
*
|
||||
* Wraps Node 22's built-in `WebAssembly` runtime with the host-side
|
||||
* limits we need: a fixed-page Memory bound and a fuel counter that
|
||||
* the capability gateway charges on every host-fn call. The intent of
|
||||
* this foundation step is to lock in the public surface every caller
|
||||
* (worker job, tests, future hot-reload) targets — Wasmer/Wasmtime can
|
||||
* later be swapped in behind the same `PluginSandbox` interface.
|
||||
*
|
||||
* What this is NOT (yet):
|
||||
* - True per-instruction fuel metering. V8 lacks a public hook for
|
||||
* instruction counting; we approximate via capability-call fuel and
|
||||
* a wall-clock timeout enforced by the caller.
|
||||
* - Wasmtime-style epoch interruption. Documented as the upgrade path.
|
||||
*
|
||||
* What this IS:
|
||||
* - A deterministic memory cap via WebAssembly.Memory `maximum`.
|
||||
* - A fuel budget that decrements on every capability gateway call.
|
||||
* - A clean `close()` so the host can drop the WebAssembly Instance
|
||||
* and release its memory back to V8.
|
||||
*/
|
||||
|
||||
export type PluginSandboxOptions = {
|
||||
wasmBuffer: Uint8Array;
|
||||
manifest: PluginManifest;
|
||||
memoryLimitMb?: number;
|
||||
/** Total host-fn budget for a single invocation. */
|
||||
fuelLimit?: number;
|
||||
/** Host imports the plugin may reference, after capability filtering. */
|
||||
hostImports?: WasmImports;
|
||||
};
|
||||
|
||||
export type PluginInvocationResult =
|
||||
| { ok: true; output: unknown; fuelUsed: number }
|
||||
| { ok: false; reason: PluginInvocationFailure; fuelUsed: number; message?: string };
|
||||
|
||||
export type PluginInvocationFailure =
|
||||
| "compile_failed"
|
||||
| "instantiate_failed"
|
||||
| "hook_not_exported"
|
||||
| "fuel_exhausted"
|
||||
| "memory_limit_exceeded"
|
||||
| "timeout"
|
||||
| "trap";
|
||||
|
||||
export type PluginSandbox = {
|
||||
/** Invoke a hook export by name with a JSON-serialisable payload. */
|
||||
invoke(hookName: string, payload: unknown): Promise<PluginInvocationResult>;
|
||||
/** Free the WebAssembly instance + memory. Idempotent. */
|
||||
close(): void;
|
||||
/** Fuel remaining in the current budget (read-only snapshot). */
|
||||
fuelRemaining(): number;
|
||||
};
|
||||
|
||||
const DEFAULT_MEMORY_LIMIT_MB = 64;
|
||||
const DEFAULT_FUEL_LIMIT = 1_000_000;
|
||||
/** 1 WebAssembly page = 64 KiB. */
|
||||
const WASM_PAGE_BYTES = 64 * 1024;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Build a sandbox. The function is sync because compilation needs to
|
||||
* be observable for error handling; the returned `invoke` is async to
|
||||
* match the host-fn shape we'll need once async I/O bindings land.
|
||||
*/
|
||||
export function createPluginSandbox(options: PluginSandboxOptions): PluginSandbox {
|
||||
const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_LIMIT_MB;
|
||||
const fuelLimit = options.fuelLimit ?? DEFAULT_FUEL_LIMIT;
|
||||
|
||||
if (memoryLimitMb <= 0) {
|
||||
throw new Error("memoryLimitMb must be positive");
|
||||
}
|
||||
if (fuelLimit <= 0) {
|
||||
throw new Error("fuelLimit must be positive");
|
||||
}
|
||||
|
||||
let fuel = fuelLimit;
|
||||
let module: WasmModule | null = null;
|
||||
let instance: WasmInstance | null = null;
|
||||
let closed = false;
|
||||
let initError: PluginInvocationFailure | null = null;
|
||||
|
||||
try {
|
||||
module = new WebAssembly.Module(options.wasmBuffer as BufferSource);
|
||||
} catch (err) {
|
||||
initError = "compile_failed";
|
||||
// Defer surfacing — the sandbox is returned in a closed state and
|
||||
// every invoke() call reports the failure. This keeps the API
|
||||
// sync-throw-free for callers.
|
||||
return {
|
||||
invoke: () =>
|
||||
Promise.resolve({ ok: false, reason: "compile_failed", fuelUsed: 0, message: stringifyError(err) }),
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
fuelRemaining: () => fuel,
|
||||
};
|
||||
}
|
||||
|
||||
const maxPages = Math.ceil((memoryLimitMb * 1024 * 1024) / WASM_PAGE_BYTES);
|
||||
const memory = new WebAssembly.Memory({ initial: 1, maximum: maxPages });
|
||||
|
||||
const hostImports: WasmImports = options.hostImports ?? {};
|
||||
let currentPayloadBytes: Uint8Array<ArrayBufferLike> = new Uint8Array();
|
||||
|
||||
// Provide the memory as a default `env.memory` import if the plugin
|
||||
// declares one and the host doesn't override it.
|
||||
const envImports: Record<string, unknown> = {
|
||||
memory,
|
||||
...(hostImports.env ?? {}),
|
||||
};
|
||||
envImports.toolctrl_payload_len = () => currentPayloadBytes.byteLength;
|
||||
envImports.toolctrl_payload_byte = (index: number) => {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= currentPayloadBytes.byteLength) return 0;
|
||||
return currentPayloadBytes[index] ?? 0;
|
||||
};
|
||||
const wrappedImports: WasmImports = { ...hostImports, env: envImports };
|
||||
|
||||
try {
|
||||
instance = new WebAssembly.Instance(module, wrappedImports);
|
||||
} catch (err) {
|
||||
initError = "instantiate_failed";
|
||||
return {
|
||||
invoke: () =>
|
||||
Promise.resolve({ ok: false, reason: "instantiate_failed", fuelUsed: 0, message: stringifyError(err) }),
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
fuelRemaining: () => fuel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
invoke: async (hookName, payload) => {
|
||||
if (closed) {
|
||||
return { ok: false, reason: "trap", fuelUsed: 0, message: "sandbox closed" };
|
||||
}
|
||||
if (initError) {
|
||||
return { ok: false, reason: initError, fuelUsed: 0 };
|
||||
}
|
||||
const exports = (instance as WasmInstance).exports;
|
||||
const hook = exports[hookName];
|
||||
if (typeof hook !== "function") {
|
||||
return { ok: false, reason: "hook_not_exported", fuelUsed: fuelLimit - fuel };
|
||||
}
|
||||
|
||||
// For the foundation we accept a numeric or scalar payload and
|
||||
// pass it straight in. Structured payloads are also exposed through
|
||||
// the minimal JSON payload ABI imports:
|
||||
// env.toolctrl_payload_len() -> i32
|
||||
// env.toolctrl_payload_byte(index: i32) -> i32
|
||||
currentPayloadBytes = encodeJsonPayload(payload);
|
||||
const arg = typeof payload === "number" || typeof payload === "bigint" ? payload : 0;
|
||||
|
||||
try {
|
||||
const before = fuel;
|
||||
// Charge a flat one-fuel cost just for the call. Capability
|
||||
// gateway charges further when imports are invoked.
|
||||
if (fuel <= 0) {
|
||||
return { ok: false, reason: "fuel_exhausted", fuelUsed: fuelLimit - fuel };
|
||||
}
|
||||
fuel -= 1;
|
||||
const out = (hook as (...args: unknown[]) => unknown)(arg);
|
||||
return { ok: true, output: out, fuelUsed: before - fuel };
|
||||
} catch (err) {
|
||||
const fuelUsed = fuelLimit - fuel;
|
||||
const message = stringifyError(err);
|
||||
if (isWasmRuntimeError(err)) {
|
||||
if (/out of bounds|memory access/iu.test(message)) {
|
||||
return { ok: false, reason: "memory_limit_exceeded", fuelUsed, message };
|
||||
}
|
||||
return { ok: false, reason: "trap", fuelUsed, message };
|
||||
}
|
||||
return { ok: false, reason: "trap", fuelUsed, message };
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
instance = null;
|
||||
module = null;
|
||||
},
|
||||
fuelRemaining: () => fuel,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeJsonPayload(payload: unknown): Uint8Array {
|
||||
const json = JSON.stringify(payload, (_key, value) => (typeof value === "bigint" ? value.toString() : value)) ?? "null";
|
||||
return textEncoder.encode(json);
|
||||
}
|
||||
|
||||
function stringifyError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function isWasmRuntimeError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
// Avoid a TS lib dependency on the `WebAssembly` namespace by name-checking.
|
||||
return err.name === "RuntimeError" || err.constructor?.name === "RuntimeError";
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { luhnValid, sanitizePluginOutput, type SanitizationStrictness } from "./sanitize.js";
|
||||
|
||||
/** Build a base64url JWT with the given JSON header + payload. */
|
||||
function makeJwt(header: object, payload: object, signature = "abcd1234"): string {
|
||||
const b64 = (obj: object) =>
|
||||
Buffer.from(JSON.stringify(obj))
|
||||
.toString("base64")
|
||||
.replace(/=+$/, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
return `${b64(header)}.${b64(payload)}.${signature}`;
|
||||
}
|
||||
|
||||
describe("luhnValid", () => {
|
||||
test("accepts a valid PAN", () => {
|
||||
expect(luhnValid("4111111111111111")).toBe(true);
|
||||
expect(luhnValid("5500000000000004")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an invalid PAN", () => {
|
||||
expect(luhnValid("1234567890123456")).toBe(false);
|
||||
expect(luhnValid("0000000000000000")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-digit input", () => {
|
||||
expect(luhnValid("4111-1111")).toBe(false);
|
||||
expect(luhnValid("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — credit cards", () => {
|
||||
test("redacts a Luhn-valid PAN", () => {
|
||||
expect(sanitizePluginOutput("paid with 4111 1111 1111 1111 today")).toBe(
|
||||
"paid with [REDACTED:credit-card] today"
|
||||
);
|
||||
});
|
||||
|
||||
test("redacts a hyphenated PAN", () => {
|
||||
expect(sanitizePluginOutput("card: 4111-1111-1111-1111.")).toBe(
|
||||
"card: [REDACTED:credit-card]."
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT redact a Luhn-invalid 16-digit string (order id false positive)", () => {
|
||||
expect(sanitizePluginOutput("order 1234567890123456 placed")).toBe(
|
||||
"order 1234567890123456 placed"
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT redact a short numeric (12 digits) — out of PAN range", () => {
|
||||
expect(sanitizePluginOutput("zip 123456789012")).toBe("zip 123456789012");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — JWT", () => {
|
||||
test("redacts a JWT whose payload + header parse as JSON object", () => {
|
||||
const jwt = makeJwt({ alg: "HS256", typ: "JWT" }, { sub: "u1", iat: 1 });
|
||||
expect(sanitizePluginOutput(`bearer ${jwt}`)).toBe("bearer [REDACTED:jwt]");
|
||||
});
|
||||
|
||||
test("does NOT redact three random dotted segments (not JSON)", () => {
|
||||
// No quote in any segment, payload doesn't parse to object.
|
||||
const fake = "foo.bar.baz";
|
||||
expect(sanitizePluginOutput(fake)).toBe(fake);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — IBAN", () => {
|
||||
test("redacts a German IBAN (DE, 22 chars)", () => {
|
||||
expect(sanitizePluginOutput("IBAN DE89370400440532013000 ok")).toBe(
|
||||
"IBAN [REDACTED:iban] ok"
|
||||
);
|
||||
});
|
||||
|
||||
test("redacts an Austrian IBAN (AT, 20 chars)", () => {
|
||||
expect(sanitizePluginOutput("AT611904300234573201 sent")).toBe("[REDACTED:iban] sent");
|
||||
});
|
||||
|
||||
test("does NOT redact a DE-prefix with wrong length", () => {
|
||||
// 18 chars total — wrong for DE (expected 22).
|
||||
expect(sanitizePluginOutput("DE893704004405320")).toBe("DE893704004405320");
|
||||
});
|
||||
|
||||
test("does NOT redact an unknown country code", () => {
|
||||
expect(sanitizePluginOutput("ZZ12345678901234567890")).toBe("ZZ12345678901234567890");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — email", () => {
|
||||
test("strict mode redacts emails", () => {
|
||||
expect(sanitizePluginOutput("contact alice@example.com", "strict")).toBe(
|
||||
"contact [REDACTED:email]"
|
||||
);
|
||||
});
|
||||
|
||||
test("moderate mode leaves emails intact", () => {
|
||||
expect(sanitizePluginOutput("contact alice@example.com", "moderate")).toBe(
|
||||
"contact alice@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("off mode leaves emails intact", () => {
|
||||
expect(sanitizePluginOutput("contact alice@example.com", "off")).toBe(
|
||||
"contact alice@example.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — API key heuristic", () => {
|
||||
test("redacts Stripe-style secret key (sk_live_)", () => {
|
||||
expect(sanitizePluginOutput("Authorization: sk_live_abcdef0123456789ABCDEFxyz")).toBe(
|
||||
"Authorization: [REDACTED:api-key]"
|
||||
);
|
||||
});
|
||||
|
||||
test("redacts GitHub PAT (ghp_)", () => {
|
||||
expect(sanitizePluginOutput("token=ghp_abcdefghijklmnopqrstuvwxyz0123456789")).toBe(
|
||||
"token=[REDACTED:api-key]"
|
||||
);
|
||||
});
|
||||
|
||||
test("redacts a high-entropy 40-char hex+alpha token", () => {
|
||||
const secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0";
|
||||
const out = sanitizePluginOutput(`key=${secret} end`);
|
||||
expect(out).toBe("key=[REDACTED:api-key] end");
|
||||
});
|
||||
|
||||
test("does NOT redact a pure-lowercase 40-char id (looks like uuid-ish)", () => {
|
||||
const id = "abcdefghijklmnopqrstuvwxyzabcdefghijklmn";
|
||||
expect(sanitizePluginOutput(`id=${id}`)).toBe(`id=${id}`);
|
||||
});
|
||||
|
||||
test("does NOT redact a short alphanumeric token (< 32 chars)", () => {
|
||||
expect(sanitizePluginOutput("hello WORLD123")).toBe("hello WORLD123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — field-name awareness", () => {
|
||||
test("strict mode redacts `password` value regardless of pattern", () => {
|
||||
const input = { user: "bob", password: "hunter2" };
|
||||
expect(sanitizePluginOutput(input, "strict")).toEqual({
|
||||
user: "bob",
|
||||
password: "[REDACTED:field]",
|
||||
});
|
||||
});
|
||||
|
||||
test("moderate mode redacts `password` value when length > 4", () => {
|
||||
expect(sanitizePluginOutput({ password: "hunter2" }, "moderate")).toEqual({
|
||||
password: "[REDACTED:field]",
|
||||
});
|
||||
});
|
||||
|
||||
test("moderate mode passes short field-name value through", () => {
|
||||
expect(sanitizePluginOutput({ password: "abcd" }, "moderate")).toEqual({
|
||||
password: "abcd",
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts `apiKey` (camelCase) value", () => {
|
||||
expect(sanitizePluginOutput({ apiKey: "supersecret" }, "moderate")).toEqual({
|
||||
apiKey: "[REDACTED:field]",
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts `api_key` (snake_case) value", () => {
|
||||
expect(sanitizePluginOutput({ api_key: "supersecret" }, "moderate")).toEqual({
|
||||
api_key: "[REDACTED:field]",
|
||||
});
|
||||
});
|
||||
|
||||
test("redacts `credit_card`, `creditCard`, `cardnumber` aliases", () => {
|
||||
expect(
|
||||
sanitizePluginOutput(
|
||||
{ credit_card: "abcdef", creditCard: "abcdef", cardnumber: "abcdef" },
|
||||
"moderate"
|
||||
)
|
||||
).toEqual({
|
||||
credit_card: "[REDACTED:field]",
|
||||
creditCard: "[REDACTED:field]",
|
||||
cardnumber: "[REDACTED:field]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — strictness modes", () => {
|
||||
test("off is identity for strings", () => {
|
||||
const s = "alice@example.com / 4111111111111111";
|
||||
expect(sanitizePluginOutput(s, "off")).toBe(s);
|
||||
});
|
||||
|
||||
test("off is identity for nested objects", () => {
|
||||
const input = { password: "hunter2", nested: { token: "abc" } };
|
||||
expect(sanitizePluginOutput(input, "off")).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — walk depth cap", () => {
|
||||
test("replaces values past depth 16 with [DEPTH_LIMIT]", () => {
|
||||
type Nest = { v: unknown };
|
||||
let value: Nest = { v: "deep-secret" };
|
||||
for (let i = 0; i < 20; i++) {
|
||||
value = { v: value };
|
||||
}
|
||||
const out = sanitizePluginOutput(value, "moderate") as Nest;
|
||||
// Walk a few levels and confirm we eventually hit the depth marker.
|
||||
let cursor: unknown = out;
|
||||
let found = false;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
if (cursor === "[DEPTH_LIMIT]") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (typeof cursor === "object" && cursor && "v" in cursor) {
|
||||
cursor = (cursor as Nest).v;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — string length cap", () => {
|
||||
test("truncates strings over 64 KB and appends [TRUNCATED]", () => {
|
||||
const big = "a".repeat(64 * 1024 + 100);
|
||||
const out = sanitizePluginOutput(big, "moderate") as string;
|
||||
expect(out.length).toBe(64 * 1024 + "[TRUNCATED]".length);
|
||||
expect(out.endsWith("[TRUNCATED]")).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves short strings untouched", () => {
|
||||
expect(sanitizePluginOutput("short", "moderate")).toBe("short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — non-string pass-through", () => {
|
||||
test.each<[unknown]>([[42], [3.14], [true], [false], [null], [undefined]])(
|
||||
"passes %s through unchanged",
|
||||
(val) => {
|
||||
expect(sanitizePluginOutput(val, "moderate")).toBe(val);
|
||||
expect(sanitizePluginOutput(val, "strict")).toBe(val);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — arrays and nested objects", () => {
|
||||
test("walks arrays element-wise", () => {
|
||||
const out = sanitizePluginOutput(["4111 1111 1111 1111", "ok", 42], "moderate");
|
||||
expect(out).toEqual(["[REDACTED:credit-card]", "ok", 42]);
|
||||
});
|
||||
|
||||
test("walks nested objects", () => {
|
||||
const out = sanitizePluginOutput(
|
||||
{ a: { b: { iban: "DE89370400440532013000" } } },
|
||||
"moderate"
|
||||
);
|
||||
expect(out).toEqual({ a: { b: { iban: "[REDACTED:field]" } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — immutability", () => {
|
||||
test("does not mutate the original object", () => {
|
||||
const original = { password: "hunter2", nested: { card: "4111 1111 1111 1111" } };
|
||||
const snapshot = JSON.parse(JSON.stringify(original));
|
||||
sanitizePluginOutput(original, "moderate");
|
||||
expect(original).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test("does not mutate the original array", () => {
|
||||
const original = ["4111 1111 1111 1111", { token: "supersecret" }];
|
||||
const snapshot = JSON.parse(JSON.stringify(original));
|
||||
sanitizePluginOutput(original, "moderate");
|
||||
expect(original).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizePluginOutput — strictness type guard", () => {
|
||||
test("accepts each strictness mode without throwing", () => {
|
||||
const modes: SanitizationStrictness[] = ["strict", "moderate", "off"];
|
||||
for (const m of modes) {
|
||||
expect(() => sanitizePluginOutput("hello", m)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// F-023 — circular reference safety.
|
||||
describe("sanitizePluginOutput — circular references (F-023)", () => {
|
||||
test("handles a self-referential object without throwing", () => {
|
||||
type Circ = { name: string; self?: Circ };
|
||||
const root: Circ = { name: "root" };
|
||||
root.self = root;
|
||||
expect(() => sanitizePluginOutput(root, "moderate")).not.toThrow();
|
||||
const out = sanitizePluginOutput(root, "moderate") as { name: string; self: unknown };
|
||||
expect(out.name).toBe("root");
|
||||
expect(out.self).toBe("[CIRCULAR]");
|
||||
});
|
||||
|
||||
test("handles two-step cycles between objects", () => {
|
||||
type Node = { tag: string; ref?: Node };
|
||||
const a: Node = { tag: "a" };
|
||||
const b: Node = { tag: "b" };
|
||||
a.ref = b;
|
||||
b.ref = a;
|
||||
expect(() => sanitizePluginOutput(a, "moderate")).not.toThrow();
|
||||
const out = sanitizePluginOutput(a, "moderate") as { tag: string; ref: { tag: string; ref: unknown } };
|
||||
expect(out.tag).toBe("a");
|
||||
expect(out.ref.tag).toBe("b");
|
||||
expect(out.ref.ref).toBe("[CIRCULAR]");
|
||||
});
|
||||
|
||||
test("handles circular arrays", () => {
|
||||
const arr: unknown[] = [1, 2];
|
||||
arr.push(arr);
|
||||
expect(() => sanitizePluginOutput(arr, "moderate")).not.toThrow();
|
||||
const out = sanitizePluginOutput(arr, "moderate") as unknown[];
|
||||
expect(out[0]).toBe(1);
|
||||
expect(out[2]).toBe("[CIRCULAR]");
|
||||
});
|
||||
});
|
||||
|
||||
// F-014 — total output-size budget across the walk.
|
||||
describe("sanitizePluginOutput — total size budget (F-014)", () => {
|
||||
const originalBudget = process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES;
|
||||
|
||||
function setBudget(bytes: number) {
|
||||
process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES = String(bytes);
|
||||
}
|
||||
|
||||
function restoreBudget() {
|
||||
if (originalBudget === undefined) {
|
||||
delete process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES;
|
||||
} else {
|
||||
process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES = originalBudget;
|
||||
}
|
||||
}
|
||||
|
||||
test("a 10 MB-equivalent nested object is bounded by the budget", () => {
|
||||
setBudget(1024); // tiny budget — 1 KB
|
||||
const big: Record<string, string> = {};
|
||||
for (let i = 0; i < 500; i++) {
|
||||
big[`k${i}`] = "abcdefghijklmnopqrstuvwxyz".repeat(20); // ~520 chars
|
||||
}
|
||||
const out = sanitizePluginOutput(big, "moderate") as Record<string, unknown>;
|
||||
restoreBudget();
|
||||
// Every key should still be in the output (we don't drop keys, only
|
||||
// values), but later values must be SIZE_LIMIT_MARKER once the
|
||||
// budget is exhausted.
|
||||
expect(Object.keys(out)).toHaveLength(500);
|
||||
const limited = Object.values(out).filter((v) => v === "[SIZE_LIMIT]");
|
||||
expect(limited.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("budget exhaustion replaces array tails with the size marker", () => {
|
||||
setBudget(64);
|
||||
const arr = Array.from({ length: 50 }, (_, i) => `value-${i}-payload-string`);
|
||||
const out = sanitizePluginOutput(arr, "moderate") as unknown[];
|
||||
restoreBudget();
|
||||
expect(out[0]).toBe("value-0-payload-string");
|
||||
expect(out[out.length - 1]).toBe("[SIZE_LIMIT]");
|
||||
});
|
||||
|
||||
test("a small payload comfortably fits within the default 1 MiB budget", () => {
|
||||
delete process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES;
|
||||
const out = sanitizePluginOutput({ a: "hello", b: [1, 2, "world"] }, "moderate");
|
||||
expect(out).toEqual({ a: "hello", b: [1, 2, "world"] });
|
||||
});
|
||||
|
||||
test("an invalid budget value falls back to the default and does not crash", () => {
|
||||
process.env.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES = "not-a-number";
|
||||
expect(() => sanitizePluginOutput({ a: "ok" }, "moderate")).not.toThrow();
|
||||
restoreBudget();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Plugin output sanitization — Phase 06 #23.
|
||||
*
|
||||
* `sanitizePluginOutput(input, strictness)` walks an arbitrary JSON-
|
||||
* serialisable value and replaces sensitive substrings inside string
|
||||
* leaves with `[REDACTED:type]` markers before the result lands in the
|
||||
* `plugin_invocations.outputSummary` audit field.
|
||||
*
|
||||
* The pattern set covers:
|
||||
* - credit-card PANs (Luhn-validated to avoid false positives)
|
||||
* - JWTs (three base64url segments, payload parseable as JSON)
|
||||
* - IBANs (country-length-aware)
|
||||
* - generic API-keys (known prefixes + long high-entropy hex/base64)
|
||||
* - emails (only in `strict` mode — legitimate plugin output often
|
||||
* contains them)
|
||||
*
|
||||
* Plus field-name awareness: keys like `password`, `token`, `iban`,
|
||||
* `card_number`, `apiKey`, … have their value redacted regardless of
|
||||
* pattern.
|
||||
*
|
||||
* Performance guardrails: walk depth is capped at 16, every string
|
||||
* leaf is truncated to 64 KB with a `[TRUNCATED]` marker. The input
|
||||
* is never mutated — fresh arrays / plain objects are returned.
|
||||
*
|
||||
* Deferred (intentionally out of scope here):
|
||||
* - OTel span sanitization
|
||||
* - tenant-admin-configurable strictness per plugin (env-default only)
|
||||
*/
|
||||
|
||||
export type SanitizationStrictness = "strict" | "moderate" | "off";
|
||||
|
||||
/** Walk depth cap — anything deeper is replaced by `[DEPTH_LIMIT]`. */
|
||||
const MAX_DEPTH = 16;
|
||||
/** Max string leaf length in bytes-ish (utf16 code units) — 64 KB. */
|
||||
const MAX_STRING_LENGTH = 64 * 1024;
|
||||
/**
|
||||
* F-014 — total accumulated string budget across one walk. Closes the
|
||||
* "10 MB nested object" case: per-string truncation alone lets a plugin
|
||||
* exfiltrate megabytes by spreading it across 1000 keys. When the
|
||||
* budget is exceeded the walk emits `[SIZE_LIMIT]` markers in place of
|
||||
* further values. Default 1 MiB — operators override via
|
||||
* `PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`.
|
||||
*/
|
||||
const DEFAULT_TOTAL_BUDGET_BYTES = 1024 * 1024;
|
||||
const TRUNCATED_MARKER = "[TRUNCATED]";
|
||||
const DEPTH_LIMIT_MARKER = "[DEPTH_LIMIT]";
|
||||
const SIZE_LIMIT_MARKER = "[SIZE_LIMIT]";
|
||||
|
||||
function resolveTotalBudget(): number {
|
||||
const raw = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
||||
?.PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES;
|
||||
if (typeof raw !== "string" || raw.length === 0) return DEFAULT_TOTAL_BUDGET_BYTES;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TOTAL_BUDGET_BYTES;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const FIELD_NAME_REDACT = new Set([
|
||||
"email",
|
||||
"iban",
|
||||
"card_number",
|
||||
"cardnumber",
|
||||
"creditcard",
|
||||
"credit_card",
|
||||
"cc",
|
||||
"pan",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"password",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Credit-card candidate: 13-19 digits, optionally separated by single
|
||||
* space or hyphen between every group. Anchored on word-boundaries so
|
||||
* we don't gobble surrounding digits.
|
||||
*/
|
||||
const CREDIT_CARD_PATTERN = /\b(?:\d[ -]?){12,18}\d\b/g;
|
||||
|
||||
/**
|
||||
* JWT shape: three base64url segments separated by dots. We then
|
||||
* verify the payload segment is parseable JSON to drop false matches.
|
||||
*/
|
||||
const JWT_PATTERN = /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
||||
|
||||
/** Email RFC-lite — good enough for sanitization purposes. */
|
||||
const EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
||||
|
||||
/**
|
||||
* IBAN candidate — 2 letter country + 2 digit check + up to 30
|
||||
* alphanumerics. We refine by checking the country-specific length.
|
||||
*/
|
||||
const IBAN_PATTERN = /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/g;
|
||||
|
||||
/** Common DACH/EU IBAN total-length table. */
|
||||
const IBAN_LENGTHS: Record<string, number> = {
|
||||
AT: 20,
|
||||
BE: 16,
|
||||
CH: 21,
|
||||
CZ: 24,
|
||||
DE: 22,
|
||||
DK: 18,
|
||||
EE: 20,
|
||||
ES: 24,
|
||||
FI: 18,
|
||||
FR: 27,
|
||||
GB: 22,
|
||||
GR: 27,
|
||||
HR: 21,
|
||||
HU: 28,
|
||||
IE: 22,
|
||||
IT: 27,
|
||||
LI: 21,
|
||||
LT: 20,
|
||||
LU: 20,
|
||||
LV: 21,
|
||||
MT: 31,
|
||||
NL: 18,
|
||||
NO: 15,
|
||||
PL: 28,
|
||||
PT: 25,
|
||||
RO: 24,
|
||||
SE: 24,
|
||||
SI: 19,
|
||||
SK: 24,
|
||||
};
|
||||
|
||||
/** Known secret prefixes — full token after prefix gets redacted. */
|
||||
const API_KEY_PREFIX_PATTERN =
|
||||
/\b(?:sk|pk|rk)_(?:test|live)_[A-Za-z0-9]{8,}\b|\bghp_[A-Za-z0-9]{20,}\b|\bgho_[A-Za-z0-9]{20,}\b|\bghs_[A-Za-z0-9]{20,}\b|\bghu_[A-Za-z0-9]{20,}\b|\bxox[abprs]-[A-Za-z0-9-]{10,}\b|\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b/g;
|
||||
|
||||
/** Generic long opaque token: >= 32 hex/base64url-ish chars. */
|
||||
const GENERIC_API_KEY_PATTERN = /\b[A-Za-z0-9_-]{32,}\b/g;
|
||||
|
||||
/**
|
||||
* Sanitize an arbitrary JSON-serialisable value. Returns a new value
|
||||
* with sensitive substrings replaced by `[REDACTED:type]` markers.
|
||||
*
|
||||
* @param input arbitrary value (string / number / boolean / null /
|
||||
* undefined / array / plain object)
|
||||
* @param strictness `strict` redacts emails, `moderate` skips them,
|
||||
* `off` is the identity function
|
||||
*/
|
||||
export function sanitizePluginOutput(
|
||||
input: unknown,
|
||||
strictness: SanitizationStrictness = "moderate"
|
||||
): unknown {
|
||||
if (strictness === "off") {
|
||||
return input;
|
||||
}
|
||||
// F-023 — track object identities we've already walked so a plugin
|
||||
// returning a circular reference doesn't blow the stack. WeakSet
|
||||
// ignores primitives + only adds real object identities.
|
||||
const seen = new WeakSet<object>();
|
||||
// F-014 — total accumulated string-bytes budget. Decremented by each
|
||||
// string leaf the walk emits; once exhausted further leaves become
|
||||
// SIZE_LIMIT_MARKER.
|
||||
const budget = { remaining: resolveTotalBudget() };
|
||||
return walk(input, strictness, 0, seen, budget);
|
||||
}
|
||||
|
||||
const CIRCULAR_MARKER = "[CIRCULAR]";
|
||||
|
||||
type WalkBudget = { remaining: number };
|
||||
|
||||
function walk(
|
||||
value: unknown,
|
||||
strictness: SanitizationStrictness,
|
||||
depth: number,
|
||||
seen: WeakSet<object>,
|
||||
budget: WalkBudget,
|
||||
): unknown {
|
||||
if (depth > MAX_DEPTH) {
|
||||
return DEPTH_LIMIT_MARKER;
|
||||
}
|
||||
if (budget.remaining <= 0) {
|
||||
return SIZE_LIMIT_MARKER;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
const t = typeof value;
|
||||
if (t === "string") {
|
||||
const sanitized = sanitizeString(value as string, strictness);
|
||||
budget.remaining -= sanitized.length;
|
||||
return sanitized;
|
||||
}
|
||||
if (t === "number" || t === "boolean" || t === "bigint") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (seen.has(value)) {
|
||||
return CIRCULAR_MARKER;
|
||||
}
|
||||
seen.add(value);
|
||||
return value.map((item) => walk(item, strictness, depth + 1, seen, budget));
|
||||
}
|
||||
if (t === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (seen.has(obj)) {
|
||||
return CIRCULAR_MARKER;
|
||||
}
|
||||
seen.add(obj);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(obj)) {
|
||||
const normalizedKey = key.toLowerCase().replace(/[-_\s]/g, "");
|
||||
const sensitiveKey =
|
||||
FIELD_NAME_REDACT.has(normalizedKey) || FIELD_NAME_REDACT.has(key.toLowerCase());
|
||||
if (sensitiveKey && typeof raw === "string") {
|
||||
if (strictness === "strict") {
|
||||
out[key] = "[REDACTED:field]";
|
||||
} else if (raw.length > 4) {
|
||||
out[key] = "[REDACTED:field]";
|
||||
} else {
|
||||
out[key] = raw;
|
||||
}
|
||||
} else {
|
||||
out[key] = walk(raw, strictness, depth + 1, seen, budget);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// functions, symbols — drop through unchanged (shouldn't appear in JSON)
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeString(raw: string, strictness: SanitizationStrictness): string {
|
||||
let s = raw;
|
||||
let truncated = false;
|
||||
if (s.length > MAX_STRING_LENGTH) {
|
||||
s = s.slice(0, MAX_STRING_LENGTH);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Order matters: JWT before generic-api-key, IBAN before generic, etc.
|
||||
s = s.replace(API_KEY_PREFIX_PATTERN, "[REDACTED:api-key]");
|
||||
s = s.replace(JWT_PATTERN, (match) => (isLikelyJwt(match) ? "[REDACTED:jwt]" : match));
|
||||
s = s.replace(IBAN_PATTERN, (match) => (isLikelyIban(match) ? "[REDACTED:iban]" : match));
|
||||
s = s.replace(CREDIT_CARD_PATTERN, (match) => {
|
||||
const digits = match.replace(/[^0-9]/g, "");
|
||||
if (digits.length < 13 || digits.length > 19) {
|
||||
return match;
|
||||
}
|
||||
return luhnValid(digits) ? "[REDACTED:credit-card]" : match;
|
||||
});
|
||||
if (strictness === "strict") {
|
||||
s = s.replace(EMAIL_PATTERN, "[REDACTED:email]");
|
||||
}
|
||||
s = s.replace(GENERIC_API_KEY_PATTERN, (match) =>
|
||||
looksLikeSecret(match) ? "[REDACTED:api-key]" : match
|
||||
);
|
||||
|
||||
if (truncated) {
|
||||
s = `${s}${TRUNCATED_MARKER}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Luhn check on a digit-only string. */
|
||||
export function luhnValid(digits: string): boolean {
|
||||
if (!/^\d+$/.test(digits)) {
|
||||
return false;
|
||||
}
|
||||
let sum = 0;
|
||||
let alt = false;
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
let d = digits.charCodeAt(i) - 48;
|
||||
if (alt) {
|
||||
d *= 2;
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
sum += d;
|
||||
alt = !alt;
|
||||
}
|
||||
return sum % 10 === 0 && sum > 0;
|
||||
}
|
||||
|
||||
function isLikelyJwt(token: string): boolean {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return false;
|
||||
}
|
||||
const [header, payload] = parts;
|
||||
if (!header || !payload) {
|
||||
return false;
|
||||
}
|
||||
// Both header and payload must decode to JSON objects.
|
||||
return isBase64UrlJsonObject(header) && isBase64UrlJsonObject(payload);
|
||||
}
|
||||
|
||||
function isBase64UrlJsonObject(segment: string): boolean {
|
||||
try {
|
||||
const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, "base64").toString("utf8");
|
||||
const parsed = JSON.parse(json);
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyIban(candidate: string): boolean {
|
||||
const country = candidate.slice(0, 2);
|
||||
const expected = IBAN_LENGTHS[country];
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
return candidate.length === expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: a long opaque token "looks like a secret" if it has high
|
||||
* character-class diversity (mixes digits + letters, or both letter
|
||||
* cases) — pure-numeric or pure-lowercase tokens are likely IDs.
|
||||
*/
|
||||
function looksLikeSecret(token: string): boolean {
|
||||
if (token.length < 32) {
|
||||
return false;
|
||||
}
|
||||
const hasUpper = /[A-Z]/.test(token);
|
||||
const hasLower = /[a-z]/.test(token);
|
||||
const hasDigit = /\d/.test(token);
|
||||
// Pure-lowercase or pure-uppercase strings (no digits) are usually
|
||||
// identifiers / slugs / uuids-without-hyphens — leave them alone.
|
||||
if (!hasDigit) {
|
||||
return false;
|
||||
}
|
||||
// Need mixed-case + digit (high entropy signal) — single-case-plus-digit
|
||||
// covers ids like timestamps too, so require both letter cases.
|
||||
if (!(hasUpper && hasLower)) {
|
||||
return false;
|
||||
}
|
||||
// Shannon-entropy proxy: require >= 12 unique chars to weed out
|
||||
// repeating patterns.
|
||||
const unique = new Set(token).size;
|
||||
return unique >= 12;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHash, generateKeyPairSync, KeyObject, sign } from "node:crypto";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { canonicalPluginSignaturePayload, parseTrustedPublicKeys, verifyPluginSignature } from "./signature.js";
|
||||
|
||||
function ed25519Keypair(): { privateKey: KeyObject; publicKeyHex: string } {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
const spki = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
||||
// SPKI prefix is 12 bytes; raw key is the last 32 bytes.
|
||||
const rawKey = spki.subarray(spki.length - 32);
|
||||
return { privateKey, publicKeyHex: rawKey.toString("hex") };
|
||||
}
|
||||
|
||||
function signBuffer(privateKey: KeyObject, wasm: Uint8Array): string {
|
||||
return sign(null, Buffer.from(wasm), privateKey).toString("hex");
|
||||
}
|
||||
|
||||
function signManifestPayload(privateKey: KeyObject, manifest: Record<string, unknown>, wasmSha256: string): string {
|
||||
return sign(null, Buffer.from(canonicalPluginSignaturePayload(manifest, wasmSha256), "utf8"), privateKey).toString("hex");
|
||||
}
|
||||
|
||||
describe("verifyPluginSignature", () => {
|
||||
const wasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
|
||||
const wasmHash = createHash("sha256").update(wasm).digest("hex");
|
||||
|
||||
test("returns ok when signature, hash and trust all match", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const signatureHex = signBuffer(privateKey, wasm);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ok for the canonical manifest-plus-wasm hash payload signed by the CLI", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.test",
|
||||
version: "1.0.0",
|
||||
name: "Test",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: wasmHash,
|
||||
signature: "0".repeat(128),
|
||||
signedBy: publicKeyHex,
|
||||
capabilities: [{ name: "log" }],
|
||||
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
|
||||
};
|
||||
const signatureHex = signManifestPayload(privateKey, manifest, wasmHash);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
manifest,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("fails when manifest content changes after signing", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const manifest = { id: "de.toolctrl.test", version: "1.0.0", capabilities: [{ name: "log" }] };
|
||||
const signatureHex = signManifestPayload(privateKey, manifest, wasmHash);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
manifest: { ...manifest, capabilities: [{ name: "audit" }] },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("signature_invalid");
|
||||
});
|
||||
|
||||
test("fails with binary_hash_mismatch when wasm bytes differ from manifest hash", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const signatureHex = signBuffer(privateKey, wasm);
|
||||
const tampered = new Uint8Array([...wasm, 0xff]);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: tampered,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("binary_hash_mismatch");
|
||||
});
|
||||
|
||||
test("fails with public_key_not_trusted when the signing key is not in the allowlist", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const { publicKeyHex: otherKey } = ed25519Keypair();
|
||||
const signatureHex = signBuffer(privateKey, wasm);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [otherKey],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("public_key_not_trusted");
|
||||
});
|
||||
|
||||
test("fails with signature_invalid when the signature is tampered", () => {
|
||||
const { privateKey, publicKeyHex } = ed25519Keypair();
|
||||
const valid = signBuffer(privateKey, wasm);
|
||||
// Flip a hex char while staying in [0-9a-f] to keep the format-check happy.
|
||||
const tampered = (valid[0] === "0" ? "1" : "0") + valid.slice(1);
|
||||
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex: tampered,
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("signature_invalid");
|
||||
});
|
||||
|
||||
test("fails with malformed_input on non-hex signature", () => {
|
||||
const { publicKeyHex } = ed25519Keypair();
|
||||
const result = verifyPluginSignature({
|
||||
wasmBuffer: wasm,
|
||||
expectedWasmSha256: wasmHash,
|
||||
signatureHex: "not-hex",
|
||||
publicKeyHex,
|
||||
trustedPublicKeysHex: [publicKeyHex],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("malformed_input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTrustedPublicKeys", () => {
|
||||
test("returns an empty list for missing / empty env", () => {
|
||||
expect(parseTrustedPublicKeys(undefined)).toEqual([]);
|
||||
expect(parseTrustedPublicKeys("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("splits a CSV and lowercases hex entries", () => {
|
||||
const a = "aa".repeat(32);
|
||||
const b = "BB".repeat(32);
|
||||
expect(parseTrustedPublicKeys(`${a},${b}`)).toEqual([a, b.toLowerCase()]);
|
||||
});
|
||||
|
||||
test("silently drops non-hex / wrong-length entries", () => {
|
||||
const valid = "cc".repeat(32);
|
||||
expect(parseTrustedPublicKeys(`junk,${valid},also-bad`)).toEqual([valid]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createHash, createPublicKey, verify } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Ed25519 signature verification for plugin binaries.
|
||||
*
|
||||
* The host trusts a small allowlist of Ed25519 public keys configured
|
||||
* via the `PLUGIN_TRUSTED_PUBLIC_KEYS` env var (CSV of hex-encoded
|
||||
* 32-byte keys). On install / before invocation, the runtime computes
|
||||
* SHA-256 over the .wasm binary, checks it against the manifest's
|
||||
* `wasmSha256`, then verifies the manifest's `signature` against the
|
||||
* canonical manifest projection plus wasm hash using the `signedBy` key
|
||||
* — and finally confirms the key appears in the trusted allowlist.
|
||||
*
|
||||
* Ed25519 was chosen over RSA / ECDSA because the construction has
|
||||
* fewer implementation-level footguns (no nonce reuse, deterministic
|
||||
* signatures), is fast on Node's built-in libcrypto, and produces a
|
||||
* compact 64-byte signature that fits the manifest cleanly.
|
||||
*/
|
||||
|
||||
export type VerifyPluginSignatureInput = {
|
||||
/** Raw .wasm bytes. */
|
||||
wasmBuffer: Uint8Array;
|
||||
/** Manifest-declared SHA-256 of the binary (hex, 64 chars). */
|
||||
expectedWasmSha256: string;
|
||||
/** Manifest-declared Ed25519 signature (hex, 128 chars). */
|
||||
signatureHex: string;
|
||||
/** Manifest-declared Ed25519 public key (hex, 64 chars). */
|
||||
publicKeyHex: string;
|
||||
/** Allowlist of trusted hex-encoded public keys (lowercase). */
|
||||
trustedPublicKeysHex: ReadonlyArray<string>;
|
||||
/** Raw manifest object. When present, verifies the same payload signed by the CLI. */
|
||||
manifest?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type VerifyPluginSignatureResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: VerifyPluginSignatureFailure };
|
||||
|
||||
export type VerifyPluginSignatureFailure =
|
||||
| "binary_hash_mismatch"
|
||||
| "signature_invalid"
|
||||
| "public_key_not_trusted"
|
||||
| "malformed_input";
|
||||
|
||||
export function canonicalPluginSignaturePayload(manifest: Record<string, unknown>, wasmSha256: string): string {
|
||||
const stripped: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(manifest).sort()) {
|
||||
if (key === "signature" || key === "signedBy" || key === "wasmSha256") continue;
|
||||
stripped[key] = manifest[key];
|
||||
}
|
||||
return `${JSON.stringify(stripped, sortDeep)}:${wasmSha256.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-function verifier. Returns a discriminated result; never throws
|
||||
* for malformed input — the caller decides whether to surface the
|
||||
* failure to operators or to a structured Sentry event.
|
||||
*/
|
||||
export function verifyPluginSignature(input: VerifyPluginSignatureInput): VerifyPluginSignatureResult {
|
||||
if (!isHex(input.expectedWasmSha256, 64)) return { ok: false, reason: "malformed_input" };
|
||||
if (!isHex(input.signatureHex, 128)) return { ok: false, reason: "malformed_input" };
|
||||
if (!isHex(input.publicKeyHex, 64)) return { ok: false, reason: "malformed_input" };
|
||||
|
||||
const actualHash = createHash("sha256").update(input.wasmBuffer).digest("hex");
|
||||
if (actualHash !== input.expectedWasmSha256.toLowerCase()) {
|
||||
return { ok: false, reason: "binary_hash_mismatch" };
|
||||
}
|
||||
|
||||
const normalisedKey = input.publicKeyHex.toLowerCase();
|
||||
if (!input.trustedPublicKeysHex.map((k) => k.toLowerCase()).includes(normalisedKey)) {
|
||||
return { ok: false, reason: "public_key_not_trusted" };
|
||||
}
|
||||
|
||||
let publicKey;
|
||||
try {
|
||||
publicKey = createPublicKey({
|
||||
key: ed25519SpkiFromRawHex(normalisedKey),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, reason: "malformed_input" };
|
||||
}
|
||||
|
||||
let valid = false;
|
||||
try {
|
||||
const signedPayload = input.manifest ? Buffer.from(canonicalPluginSignaturePayload(input.manifest, actualHash), "utf8") : input.wasmBuffer;
|
||||
valid = verify(
|
||||
null,
|
||||
signedPayload,
|
||||
publicKey,
|
||||
Buffer.from(input.signatureHex, "hex")
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, reason: "signature_invalid" };
|
||||
}
|
||||
|
||||
return valid ? { ok: true } : { ok: false, reason: "signature_invalid" };
|
||||
}
|
||||
|
||||
function sortDeep(_key: string, value: unknown): unknown {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[k] = (value as Record<string, unknown>)[k];
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `PLUGIN_TRUSTED_PUBLIC_KEYS` env CSV into a normalised
|
||||
* lowercase hex array. Empty / missing input → empty list (signature
|
||||
* verification will then fail closed, which is the intended default
|
||||
* in any environment that has not been seeded yet).
|
||||
*/
|
||||
export function parseTrustedPublicKeys(raw: string | undefined): ReadonlyArray<string> {
|
||||
if (typeof raw !== "string" || raw.length === 0) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => isHex(s, 64));
|
||||
}
|
||||
|
||||
function isHex(value: unknown, length: number): value is string {
|
||||
return typeof value === "string" && value.length === length && /^[0-9a-fA-F]+$/u.test(value);
|
||||
}
|
||||
|
||||
// Ed25519 raw public keys are 32 bytes. Node's `createPublicKey` needs
|
||||
// either SPKI DER, PEM, or JWK. We wrap the raw key in the well-known
|
||||
// 12-byte Ed25519 SPKI prefix so we don't pull in a third-party library.
|
||||
// Prefix bytes: SEQUENCE/SEQUENCE/OID(1.3.101.112)/BIT_STRING_HEADER.
|
||||
const ED25519_SPKI_PREFIX_HEX = "302a300506032b6570032100";
|
||||
|
||||
function ed25519SpkiFromRawHex(publicKeyHex: string): Buffer {
|
||||
return Buffer.from(ED25519_SPKI_PREFIX_HEX + publicKeyHex, "hex");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isPluginCompatibleWithHost } from "./version-check.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { PluginVersionConstraints } from "./version-check.js";
|
||||
|
||||
const hex64 = "0".repeat(64);
|
||||
const hex128 = "0".repeat(128);
|
||||
|
||||
function manifest(overrides: PluginVersionConstraints = {}): PluginManifest & PluginVersionConstraints {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "de.toolctrl.hello",
|
||||
version: "1.0.0",
|
||||
name: "Hello",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: hex64,
|
||||
signature: hex128,
|
||||
signedBy: hex64,
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks: [{ name: "tenant.created", export: "onTenantCreated" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isPluginCompatibleWithHost", () => {
|
||||
test("compatible when host equals minHostVersion exactly", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ minHostVersion: "1.0.0" }), "1.0.0");
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
test("host_below_min when host is a patch below minHostVersion", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ minHostVersion: "1.0.1" }), "1.0.0");
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("host_below_min");
|
||||
expect(result.details).toContain("1.0.1");
|
||||
}
|
||||
});
|
||||
|
||||
test("host_above_max when host is a patch above maxHostVersion", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ maxHostVersion: "1.0.0" }), "1.0.1");
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("host_above_max");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid_host_version when hostVersion is malformed", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ minHostVersion: "1.0.0" }), "not-a-version");
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("invalid_host_version");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid_plugin_range when minHostVersion is malformed", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ minHostVersion: "v.bad" }), "1.0.0");
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("invalid_plugin_range");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid_plugin_range when maxHostVersion is malformed", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ maxHostVersion: "garbage" }), "1.0.0");
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("invalid_plugin_range");
|
||||
}
|
||||
});
|
||||
|
||||
test("pre-release host satisfies stable minHostVersion via includePrerelease", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ minHostVersion: "1.0.0" }), "1.0.0-rc.1");
|
||||
// With includePrerelease, 1.0.0-rc.1 >= 1.0.0 evaluates as compatible
|
||||
// (in standard semver semantics 1.0.0-rc.1 < 1.0.0 by spec, but our
|
||||
// policy says RC hosts accept plugins authored against the GA).
|
||||
// We document this by requiring this case to be compatible.
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
test("returns compatible when neither min nor max declared (back-compat)", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest(), "1.7.0");
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
test("boundary: maxHostVersion inclusive — host equals max is compatible", () => {
|
||||
const result = isPluginCompatibleWithHost(manifest({ maxHostVersion: "2.0.0" }), "2.0.0");
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
test("compatible when host sits inside [min, max]", () => {
|
||||
const result = isPluginCompatibleWithHost(
|
||||
manifest({ minHostVersion: "1.0.0", maxHostVersion: "2.0.0" }),
|
||||
"1.5.3",
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
test("host_above_max takes precedence over equal-max edge cases", () => {
|
||||
const result = isPluginCompatibleWithHost(
|
||||
manifest({ minHostVersion: "1.0.0", maxHostVersion: "1.0.0" }),
|
||||
"1.0.1",
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
if (!result.compatible) {
|
||||
expect(result.reason).toBe("host_above_max");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Plugin / host version compatibility — Phase 06 #23 (audit-docs Step 12).
|
||||
*
|
||||
* Plugins may declare an optional version range against the host runtime
|
||||
* via `minHostVersion` / `maxHostVersion` (inclusive). The manifest Zod
|
||||
* schema does not yet carry these fields (they remain "reserved" per
|
||||
* `docs/plugins/versioning.md` until the marketplace work lands), but
|
||||
* the install path threads them through opportunistically: when the
|
||||
* manifest object carries either field, this function decides whether
|
||||
* the running host satisfies the declared range.
|
||||
*
|
||||
* Pre-release handling: a host running a pre-release tag (e.g. `1.2.0-rc.1`)
|
||||
* is treated as satisfying a stable plugin range (`includePrerelease: true`).
|
||||
* This lets RC hosts install plugins authored against the upcoming GA
|
||||
* without forcing every plugin author to special-case prereleases.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
/**
|
||||
* Optional host-version range fields. Defined separately from the Zod
|
||||
* `PluginManifest` because the schema does not yet enforce them — the
|
||||
* manifest object reaching this function may or may not carry them.
|
||||
*/
|
||||
export type PluginVersionConstraints = {
|
||||
minHostVersion?: string;
|
||||
maxHostVersion?: string;
|
||||
};
|
||||
|
||||
export type PluginVersionCheckIncompatible =
|
||||
| "host_below_min"
|
||||
| "host_above_max"
|
||||
| "invalid_host_version"
|
||||
| "invalid_plugin_range";
|
||||
|
||||
export type PluginVersionCheckResult =
|
||||
| { compatible: true }
|
||||
| { compatible: false; reason: PluginVersionCheckIncompatible; details: string };
|
||||
|
||||
/**
|
||||
* Decide whether `hostVersion` falls within the manifest's optional
|
||||
* `[minHostVersion, maxHostVersion]` window. Bounds are inclusive.
|
||||
*
|
||||
* - Returns `{ compatible: true }` when the manifest declares neither
|
||||
* bound (back-compat for plugins authored before the field was
|
||||
* enforced).
|
||||
* - Returns `{ compatible: false, reason: 'invalid_host_version' }`
|
||||
* when `hostVersion` is not a parseable SemVer string. The caller
|
||||
* should treat this as an operator misconfiguration, not a plugin
|
||||
* problem.
|
||||
* - Returns `{ compatible: false, reason: 'invalid_plugin_range' }`
|
||||
* when either declared bound is not a parseable SemVer string. The
|
||||
* plugin should be rejected — its manifest is malformed in a way
|
||||
* the foundation schema does not catch.
|
||||
*/
|
||||
export function isPluginCompatibleWithHost(
|
||||
manifest: PluginManifest & PluginVersionConstraints,
|
||||
hostVersion: string,
|
||||
): PluginVersionCheckResult {
|
||||
const min = manifest.minHostVersion;
|
||||
const max = manifest.maxHostVersion;
|
||||
|
||||
if (min === undefined && max === undefined) {
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
if (!semver.valid(hostVersion)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: "invalid_host_version",
|
||||
details: `Host version '${hostVersion}' is not a valid SemVer string.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (min !== undefined && !semver.valid(min)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: "invalid_plugin_range",
|
||||
details: `Plugin minHostVersion '${min}' is not a valid SemVer string.`,
|
||||
};
|
||||
}
|
||||
if (max !== undefined && !semver.valid(max)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: "invalid_plugin_range",
|
||||
details: `Plugin maxHostVersion '${max}' is not a valid SemVer string.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-release hosts (e.g. `1.2.0-rc.1`) compare against the stable
|
||||
// equivalent of their base triplet so that a host on an RC matches
|
||||
// ranges authored by plugin authors who do not pin to prerelease
|
||||
// tags themselves. Under strict SemVer, `1.0.0-rc.1 < 1.0.0`, but
|
||||
// our policy is that an RC for a release accepts plugins authored
|
||||
// against the upcoming GA — equivalent to `includePrerelease` in
|
||||
// npm's range semantics.
|
||||
const isHostPrerelease = hostVersion.includes("-");
|
||||
const compareHost = isHostPrerelease
|
||||
? (semver.coerce(hostVersion)?.version ?? hostVersion)
|
||||
: hostVersion;
|
||||
|
||||
if (min !== undefined && semver.lt(compareHost, min)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: "host_below_min",
|
||||
details: `Host version ${hostVersion} is below the plugin's minimum ${min}.`,
|
||||
};
|
||||
}
|
||||
if (max !== undefined && semver.gt(compareHost, max)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: "host_above_max",
|
||||
details: `Host version ${hostVersion} is above the plugin's maximum ${max}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { compatible: true };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user