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
6 changes: 4 additions & 2 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
contentHash,
loadOperatorConfig,
exceedsRiskThreshold,
resolvePluginSource,
} from "../plugin-system/manager.js";
import { deepAudit, formatAuditResult } from "../plugin-system/auditor.js";
import { extractSuggestedCommands } from "./command-suggestions.js";
Expand Down Expand Up @@ -701,11 +702,12 @@ if (discoveredCount > 0) {
async function syncPluginsToSandbox(): Promise<void> {
const enabled = pluginManager.getEnabledPlugins();

// Dynamic-import each enabled plugin's index.ts to get the register fn
// Dynamic-import each enabled plugin to get the register fn
const registrations = [];
const loadErrors: string[] = [];
for (const plugin of enabled) {
const indexPath = join(plugin.dir, "index.ts");
// Resolve .ts (dev) or .js (npm/dist) — centralised in plugin-system
const indexPath = resolvePluginSource(plugin.dir);

// SECURITY CHECK 1: Verify source hasn't changed since audit/approval
if (!pluginManager.verifySourceHash(plugin.manifest.name)) {
Expand Down
11 changes: 11 additions & 0 deletions src/agent/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,17 @@ export async function handleSlashCommand(
break;
}

// Load source before enabling — syncPluginsToSandbox needs
// plugin.source for verifySourceHash(). Without this, the
// hash check fails and the plugin is silently disabled.
if (!pluginManager.loadSource(pluginName)) {
console.log(
` ${C.err("❌ Failed to load source for")} "${pluginName}". Plugin will not be enabled.`,
);
console.log();
break;
}

pluginManager.enable(pluginName);
console.log(
` ✅ Plugin "${pluginName}" enabled (approved fast-path).`,
Expand Down
77 changes: 62 additions & 15 deletions src/plugin-system/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,22 +308,51 @@ export function contentHash(source: string): string {
return createHash("sha256").update(source, "utf8").digest("hex");
}

/**
* Resolve the source file for a plugin directory.
*
* In dev mode (source repo), prefers .ts so edits are reflected immediately
* without a rebuild. Under node_modules (npm install / bundled binary),
* Node.js refuses to strip types from .ts files, so we always use .js.
*/
export function resolvePluginSource(pluginDir: string): string {
const tsPath = join(pluginDir, "index.ts");
const jsPath = join(pluginDir, "index.js");

// Under node_modules, Node.js can't type-strip .ts — always use .js.
// Use path-segment check to avoid false positives on dirs named "node_modules_foo".
const underNodeModules =
/[\\/]node_modules[\\/]/.test(pluginDir) ||
pluginDir.startsWith("node_modules/");
if (underNodeModules) {
return jsPath;
}

// Dev mode: prefer .ts for live editing, fall back to .js
return existsSync(tsPath) ? tsPath : jsPath;
}

/**
* Compute combined hash of plugin source and manifest.
* Used for approval fingerprint and tamper detection.
* Any change to either file invalidates the approval.
*
* Note: approvals are scoped to the install context. A plugin approved
* in dev (from .ts) must be re-approved when loaded from node_modules
* (.js), since the source content differs. This is intentional —
* the compiled output should be verified independently.
*/
export function computePluginHash(pluginDir: string): string | null {
const tsPath = join(pluginDir, "index.ts");
const sourcePath = resolvePluginSource(pluginDir);
const jsonPath = join(pluginDir, "plugin.json");

try {
const tsContent = readFileSync(tsPath, "utf8");
const sourceContent = readFileSync(sourcePath, "utf8");
const jsonContent = readFileSync(jsonPath, "utf8");

// Hash both files together — any change invalidates
return createHash("sha256")
.update(tsContent, "utf8")
.update(sourceContent, "utf8")
.update(jsonContent, "utf8")
.digest("hex");
} catch {
Expand Down Expand Up @@ -648,16 +677,17 @@ export function createPluginManager(pluginsDir: string) {
// ── Source Loading ────────────────────────────────────────────

/**
* Load the source code of a plugin's index.js for auditing.
* Load the source code of a plugin for auditing.
* Resolves .ts (dev) or .js (npm/dist) via resolvePluginSource().
* Returns the source string, or null if the file doesn't exist.
*/
function loadSource(name: string): string | null {
const plugin = plugins.get(name);
if (!plugin) return null;

const indexPath = join(plugin.dir, "index.ts");
const indexPath = resolvePluginSource(plugin.dir);
if (!existsSync(indexPath)) {
console.error(`[plugins] Warning: ${name}/index.ts not found`);
console.error(`[plugins] Warning: ${indexPath} not found`);
return null;
}

Expand All @@ -667,7 +697,7 @@ export function createPluginManager(pluginsDir: string) {
return source;
} catch (err) {
console.error(
`[plugins] Warning: failed to read ${name}/index.ts: ${(err as Error).message}`,
`[plugins] Warning: failed to read ${name} source: ${(err as Error).message}`,
);
return null;
}
Expand All @@ -688,10 +718,27 @@ export function createPluginManager(pluginsDir: string) {
const plugin = plugins.get(name);
if (!plugin) return false;

// Load source if not already loaded
// For schema extraction, prefer .ts source — it's the canonical
// schema definition and the Rust parser handles it best. Under
// node_modules, only .js exists so we fall back gracefully.
// try/catch guards against TOCTOU (file deleted between exists check and read).
const tsPath = join(plugin.dir, "index.ts");
let extractionSource: string | null = null;
if (existsSync(tsPath)) {
try {
extractionSource = readFileSync(tsPath, "utf8");
} catch {
// Fall through to plugin.source / loadSource
}
}
if (!extractionSource) {
extractionSource = plugin.source ?? loadSource(name);
}
if (!extractionSource) return false;

// Also ensure plugin.source is loaded for hash verification
if (!plugin.source) {
const source = loadSource(name);
if (!source) return false;
if (!loadSource(name)) return false;
}

// If analysis guest is not enabled, fall back to manifest.
Expand All @@ -705,7 +752,7 @@ export function createPluginManager(pluginsDir: string) {
}

try {
const metadata = await extractPluginMetadata(plugin.source!);
const metadata = await extractPluginMetadata(extractionSource);

// Use extracted schema or fall back to manifest
if (metadata.schema) {
Expand Down Expand Up @@ -804,7 +851,7 @@ export function createPluginManager(pluginsDir: string) {
* Approve a plugin. Requires an existing audit result.
* Persists the approval to disk immediately.
*
* Uses combined hash (index.ts + plugin.json) for tamper detection.
* Uses combined hash (plugin source + plugin.json) for tamper detection.
*
* @returns true if approved, false if plugin not found or not audited
*/
Expand Down Expand Up @@ -851,7 +898,7 @@ export function createPluginManager(pluginsDir: string) {

/**
* Check if a plugin has a valid, current approval.
* Compares the stored content hash against combined hash (index.ts + plugin.json).
* Compares the stored content hash against combined hash (plugin source + plugin.json).
*/
function isApproved(name: string): boolean {
const plugin = plugins.get(name);
Expand All @@ -869,7 +916,7 @@ export function createPluginManager(pluginsDir: string) {

/**
* Refresh the `approved` flag on all plugins based on the
* persisted approval store and current combined hash (index.ts + plugin.json).
* persisted approval store and current combined hash (plugin source + plugin.json).
* Called after discover() to sync runtime flags with disk state.
*/
function refreshAllApprovals(): void {
Expand Down Expand Up @@ -1167,7 +1214,7 @@ export function createPluginManager(pluginsDir: string) {
const plugin = plugins.get(name);
if (!plugin || !plugin.source) return false;

const indexPath = join(plugin.dir, "index.ts");
const indexPath = resolvePluginSource(plugin.dir);
try {
const currentSource = readFileSync(indexPath, "utf8");
return currentSource === plugin.source;
Expand Down
56 changes: 56 additions & 0 deletions tests/plugin-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
coerceConfigValue,
loadOperatorConfig,
exceedsRiskThreshold,
resolvePluginSource,
} from "../src/plugin-system/manager.js";

// ── Fixtures path ────────────────────────────────────────────────────
Expand Down Expand Up @@ -446,6 +447,61 @@ describe("contentHash", () => {
});
});

// ── resolvePluginSource ──────────────────────────────────────────────

describe("resolvePluginSource", () => {
// Create temp fixtures with controlled file combinations
const tmpBase = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"tmp-resolve-test-" + process.pid,
);
const devDir = join(tmpBase, "dev-plugin");
const devJsOnly = join(tmpBase, "dev-js-only");
const nmDir = join(tmpBase, "node_modules", "test-plugin");

beforeEach(async () => {
const { mkdirSync, writeFileSync: fsWrite } = await import("node:fs");
mkdirSync(devDir, { recursive: true });
fsWrite(join(devDir, "index.ts"), "export const x = 1;");
fsWrite(join(devDir, "index.js"), "exports.x = 1;");

mkdirSync(devJsOnly, { recursive: true });
fsWrite(join(devJsOnly, "index.js"), "exports.x = 1;");

mkdirSync(nmDir, { recursive: true });
fsWrite(join(nmDir, "index.ts"), "export const x = 1;");
fsWrite(join(nmDir, "index.js"), "exports.x = 1;");
});

afterEach(async () => {
const { rmSync } = await import("node:fs");
rmSync(tmpBase, { recursive: true, force: true });
});

it("should prefer .ts over .js in dev (non-node_modules) dirs", () => {
const result = resolvePluginSource(devDir);
expect(result).toMatch(/index\.ts$/);
});

it("should fall back to .js when .ts does not exist in dev", () => {
const result = resolvePluginSource(devJsOnly);
expect(result).toMatch(/index\.js$/);
});

it("should always return .js under node_modules paths", () => {
const result = resolvePluginSource(nmDir);
expect(result).toMatch(/index\.js$/);
});

it("should return .js path even if .js missing under node_modules", () => {
unlinkSync(join(nmDir, "index.js"));
const result = resolvePluginSource(nmDir);
// Must return .js (not .ts) — Node can't strip types under node_modules
expect(result).toMatch(/index\.js$/);
});
});

// ── Plugin Manager ───────────────────────────────────────────────────

describe("createPluginManager", () => {
Expand Down
Loading