Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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");
});
});
@@ -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"]
}