feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
This commit is contained in:
LukeGus committed 2026-09-21 07:19:01 -05:00
1 parent 1393fa7581
commit 4311a0e98a
4 files changed
+1779

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
/**
* The grant check every privileged ctx call goes through.
*
* Capability strings are the manifest's own permission enum, stored verbatim in
* plugin_permission_grants.capability -- no translation layer, so the manifest
* stays the single source of truth for what is grantable.
*
* Two separate things have to be true for a capability to count as granted:
* the manifest must declare it (the plugin asked for it up front) and an admin
* must have granted it (someone said yes). A grant for something the manifest
* never declared is ignored rather than honoured, so widening a plugin's reach
* always requires a new manifest the user can see.
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import type { PluginPermission } from "./manifest.js";
const CACHE_TTL_MS = 60_000;
interface CacheEntry {
capabilities: Set<string>;
expiresAt: number;
}
const cache = new Map<string, CacheEntry>();
export class PluginPermissionError extends Error {
readonly code = "EPLUGINPERM";
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's permissions array and grant it in the plugin's settings.`,
);
this.name = "PluginPermissionError";
}
}
export function invalidatePluginPermissionCache(pluginId?: string): void {
if (pluginId) cache.delete(pluginId);
else cache.clear();
}
async function loadGrants(pluginId: string): Promise<Set<string>> {
const cached = cache.get(pluginId);
if (cached && cached.expiresAt > Date.now()) return cached.capabilities;
const rows =
await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId);
const capabilities = new Set(rows.map((row) => row.capability));
cache.set(pluginId, { capabilities, expiresAt: Date.now() + CACHE_TTL_MS });
return capabilities;
}
export async function hasCapability(
pluginId: string,
capability: PluginPermission | string,
declared: readonly string[],
): Promise<boolean> {
if (!declared.includes(capability)) return false;
return (await loadGrants(pluginId)).has(capability);
}
export async function assertCapability(
pluginId: string,
capability: PluginPermission | string,
declared: readonly string[],
): Promise<void> {
if (!(await hasCapability(pluginId, capability, declared))) {
throw new PluginPermissionError(pluginId, capability);
}
}