Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM=noreply@agentbase.dev

# --- Plugin Settings Encryption (AES-256-GCM key for encrypted plugin settings) ---
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
PLUGIN_SETTINGS_ENCRYPTION_KEY=

# --- File Uploads (Phase 5) ---
S3_BUCKET=
S3_REGION=us-east-1
Expand Down
87 changes: 66 additions & 21 deletions packages/core/src/modules/plugins/engine/plugin-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
* install → activate → deactivate → uninstall
*/

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HookEngine } from '../../hooks/hook.engine';
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { HookEngine } from "../../hooks/hook.engine";
import {
InstalledPlugin,
InstalledPluginStatus,
} from '../../../database/entities/installed-plugin.entity';
} from "../../../database/entities/installed-plugin.entity";
import { Plugin } from "../../../database/entities/plugin.entity";
import { encryptSetting } from "./plugin-settings-crypto";

@Injectable()
export class PluginManager {
Expand All @@ -21,15 +23,22 @@ export class PluginManager {
constructor(
@InjectRepository(InstalledPlugin)
private readonly installedRepo: Repository<InstalledPlugin>,
@InjectRepository(Plugin)
private readonly pluginRepo: Repository<Plugin>,
private readonly hookEngine: HookEngine,
) {}

async install(applicationId: string, pluginId: string): Promise<InstalledPlugin> {
async install(
applicationId: string,
pluginId: string,
): Promise<InstalledPlugin> {
const existing = await this.installedRepo.findOne({
where: { applicationId, pluginId },
});
if (existing) {
this.logger.warn(`Plugin ${pluginId} already installed for app ${applicationId}`);
this.logger.warn(
`Plugin ${pluginId} already installed for app ${applicationId}`,
);
return existing;
}

Expand All @@ -41,26 +50,41 @@ export class PluginManager {
});
const saved = await this.installedRepo.save(installed);

await this.hookEngine.doAction('plugin:installed', { applicationId, pluginId });
await this.hookEngine.doAction("plugin:installed", {
applicationId,
pluginId,
});
this.logger.log(`Plugin installed: ${pluginId} for app ${applicationId}`);
return saved;
}

async activate(applicationId: string, pluginId: string): Promise<InstalledPlugin> {
async activate(
applicationId: string,
pluginId: string,
): Promise<InstalledPlugin> {
const installed = await this.findInstalled(applicationId, pluginId);
installed.status = InstalledPluginStatus.ACTIVE;
const saved = await this.installedRepo.save(installed);
await this.hookEngine.doAction('plugin:activated', { applicationId, pluginId });
await this.hookEngine.doAction("plugin:activated", {
applicationId,
pluginId,
});
this.logger.log(`Plugin activated: ${pluginId} for app ${applicationId}`);
return saved;
}

async deactivate(applicationId: string, pluginId: string): Promise<InstalledPlugin> {
async deactivate(
applicationId: string,
pluginId: string,
): Promise<InstalledPlugin> {
const installed = await this.findInstalled(applicationId, pluginId);
installed.status = InstalledPluginStatus.INACTIVE;
const saved = await this.installedRepo.save(installed);
this.hookEngine.removePluginHooks(pluginId);
await this.hookEngine.doAction('plugin:deactivated', { applicationId, pluginId });
await this.hookEngine.doAction("plugin:deactivated", {
applicationId,
pluginId,
});
this.logger.log(`Plugin deactivated: ${pluginId} for app ${applicationId}`);
return saved;
}
Expand All @@ -69,23 +93,28 @@ export class PluginManager {
const installed = await this.findInstalled(applicationId, pluginId);
this.hookEngine.removePluginHooks(pluginId);
await this.installedRepo.remove(installed);
await this.hookEngine.doAction('plugin:uninstalled', { applicationId, pluginId });
this.logger.log(`Plugin uninstalled: ${pluginId} from app ${applicationId}`);
await this.hookEngine.doAction("plugin:uninstalled", {
applicationId,
pluginId,
});
this.logger.log(
`Plugin uninstalled: ${pluginId} from app ${applicationId}`,
);
}

async listInstalled(applicationId: string): Promise<InstalledPlugin[]> {
return this.installedRepo.find({
where: { applicationId },
relations: ['plugin'],
order: { executionOrder: 'ASC', createdAt: 'ASC' },
relations: ["plugin"],
order: { executionOrder: "ASC", createdAt: "ASC" },
});
}

async listActive(applicationId: string): Promise<InstalledPlugin[]> {
return this.installedRepo.find({
where: { applicationId, status: InstalledPluginStatus.ACTIVE },
relations: ['plugin'],
order: { executionOrder: 'ASC' },
relations: ["plugin"],
order: { executionOrder: "ASC" },
});
}

Expand All @@ -95,7 +124,21 @@ export class PluginManager {
settings: Record<string, any>,
): Promise<InstalledPlugin> {
const installed = await this.findInstalled(applicationId, pluginId);
installed.settings = { ...installed.settings, ...settings };

const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } });
const settingDefs = plugin?.manifest?.settings ?? {};

const encryptedSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(settings)) {
const def = settingDefs[key];
if (def?.encrypted === true && typeof value === "string") {
encryptedSettings[key] = encryptSetting(value);
} else {
encryptedSettings[key] = value;
}
}

installed.settings = { ...installed.settings, ...encryptedSettings };
return this.installedRepo.save(installed);
}

Expand All @@ -105,10 +148,12 @@ export class PluginManager {
): Promise<InstalledPlugin> {
const installed = await this.installedRepo.findOne({
where: { applicationId, pluginId },
relations: ['plugin'],
relations: ["plugin"],
});
if (!installed) {
throw new NotFoundException('Plugin is not installed for this application');
throw new NotFoundException(
"Plugin is not installed for this application",
);
}
return installed;
}
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/modules/plugins/engine/plugin-settings-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as crypto from "crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const ENCRYPTED_PREFIX = "enc:";

function getKey(): Buffer {
const raw = process.env.PLUGIN_SETTINGS_ENCRYPTION_KEY;
if (!raw) {
throw new Error(
"PLUGIN_SETTINGS_ENCRYPTION_KEY is not set. " +
"Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
);
}
const buf = Buffer.from(raw, "hex");
if (buf.length !== 32) {
throw new Error(
"PLUGIN_SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes).",
);
}
return buf;
}

/**
* Encrypts a plaintext string using AES-256-GCM.
* Returns a string of the form: enc:<iv_hex>:<authTag_hex>:<ciphertext_hex>
*/
export function encryptSetting(plaintext: string): string {
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return (
ENCRYPTED_PREFIX +
iv.toString("hex") +
":" +
authTag.toString("hex") +
":" +
encrypted.toString("hex")
);
}

/**
* Decrypts a value that was encrypted by encryptSetting().
* If the value does not start with the "enc:" prefix it is returned as-is
* (allows gradual migration of settings that were saved before encryption was enabled).
*/
export function decryptSetting(value: string): string {
if (!value.startsWith(ENCRYPTED_PREFIX)) {
return value;
}
const key = getKey();
const parts = value.slice(ENCRYPTED_PREFIX.length).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted setting format.");
}
const [ivHex, authTagHex, ciphertextHex] = parts;
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex").subarray(0, AUTH_TAG_LENGTH);
const ciphertext = Buffer.from(ciphertextHex, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8");
}

/**
* Returns true if the value looks like an encrypted setting (has the enc: prefix).
*/
export function isEncryptedSetting(value: unknown): value is string {
return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX);
}
55 changes: 54 additions & 1 deletion packages/core/src/modules/plugins/installed-plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
} from "../../database/entities";
import { HookEngine } from "../hooks/hook.engine";
import { LicenseValidatorService } from "../marketplace/license-validator.service";
import {
encryptSetting,
decryptSetting,
} from "./engine/plugin-settings-crypto";

@Injectable()
export class InstalledPluginsService {
Expand Down Expand Up @@ -167,10 +171,59 @@ export class InstalledPluginsService {
settings: Record<string, any>,
): Promise<InstalledPlugin> {
const installed = await this.getInstalled(applicationId, pluginId, ownerId);
installed.settings = { ...installed.settings, ...settings };

const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } });
const settingDefs = plugin?.manifest?.settings ?? {};

const encryptedSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(settings)) {
const def = settingDefs[key];
if (def?.encrypted === true && typeof value === "string") {
encryptedSettings[key] = encryptSetting(value);
} else {
encryptedSettings[key] = value;
}
}

installed.settings = { ...installed.settings, ...encryptedSettings };
return this.installedRepo.save(installed);
}

/**
* Returns the plugin's settings with encrypted fields decrypted.
* Use this when constructing a PluginContext so plugins receive plaintext values.
*/
async getSettingsForContext(
applicationId: string,
pluginId: string,
): Promise<Record<string, any>> {
const installed = await this.installedRepo.findOne({
where: { applicationId, pluginId },
});
if (!installed) return {};

const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } });
const settingDefs = plugin?.manifest?.settings ?? {};

const result: Record<string, any> = {};
for (const [key, value] of Object.entries(installed.settings ?? {})) {
const def = settingDefs[key];
if (def?.encrypted === true && typeof value === "string") {
try {
result[key] = decryptSetting(value);
} catch {
this.logger.warn(
`Failed to decrypt setting "${key}" for plugin ${pluginId}`,
);
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}

async getInstalledPlugins(
applicationId: string,
ownerId: string,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface PluginDefinition {
label: string;
default?: any;
options?: string[];
encrypted?: true;
}
>;
/** Custom API endpoints registered by this plugin. */
Expand Down
Loading