fix: harden the plugin runtime after the 2.9.0 security review

This commit is contained in:
LukeGus committed 2026-09-25 08:48:54 -05:00
1 parent 2961297989
commit c6a44b7ff3
69 files changed
+4613 -312

No files matched your search

+75 -2
View File
@@ -10,10 +10,14 @@
*
* The cache is invalidated explicitly on every grant change rather than by
* TTL: a revoke that takes effect a minute later is not a revoke.
*
* Every refusal is audited here, so a guard added anywhere in the runtime
* leaves a trail without each call site remembering to write one.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { getActor } from "./actor.js";
const cache = new Map<string, Set<string>>();
@@ -43,12 +47,81 @@ export async function hasCapability(
return (await loadGrants(pluginId)).has(capability);
}
/**
* For hooks ssh2 calls synchronously. Answers from the cache only and fails
* closed when the grants are not loaded yet, starting the load for next time.
*/
export function hasCachedCapability(
pluginId: string,
capability: string,
declared: readonly string[],
): boolean {
if (!declared.includes(capability)) return false;
const cached = cache.get(pluginId);
if (!cached) {
void loadGrants(pluginId).catch(() => {});
return false;
}
return cached.has(capability);
}
/** Loads a plugin's grants ahead of a synchronous hasCachedCapability. */
export function warmPluginGrants(pluginId: string): void {
void loadGrants(pluginId).catch(() => {});
}
/**
* Throws PluginCapabilityError unless granted, after writing the refusal's
* audit line. `action` names the call ("kv_set"), so the line says what was
* refused and not only which capability was missing.
*/
export async function assertCapability(
pluginId: string,
capability: string,
declared: readonly string[],
action = "capability_refused",
): Promise<void> {
if (!(await hasCapability(pluginId, capability, declared))) {
throw new PluginCapabilityError(pluginId, capability);
if (await hasCapability(pluginId, capability, declared)) return;
const error = new PluginCapabilityError(pluginId, capability);
await auditRefusal(pluginId, capability, action, error);
throw error;
}
/**
* The error for a capability a sync call never declared, with its audit
* line written in the background.
*/
export function capabilityRefused(
pluginId: string,
capability: string,
action = "capability_refused",
): PluginCapabilityError {
const error = new PluginCapabilityError(pluginId, capability);
void auditRefusal(pluginId, capability, action, error);
return error;
}
async function auditRefusal(
pluginId: string,
capability: string,
action: string,
error: Error,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
username: `plugin:${pluginId}`,
action: `plugin_${action}`,
resourceType: "plugin",
resourceId: pluginId,
resourceName: pluginId,
details: `refused: ${capability}`,
success: false,
errorMessage: error.message,
});
} catch {
// Auditing must never change what the caller sees.
}
}