diff --git a/packages/core/src/services/policy.ts b/packages/core/src/services/policy.ts index 2dfd74e..d062d31 100644 --- a/packages/core/src/services/policy.ts +++ b/packages/core/src/services/policy.ts @@ -1,8 +1,11 @@ import fs from "fs/promises"; import path from "path"; +import { pathToFileURL } from "url"; import { readJson, stripJsonComments } from "../utils/fs"; +import type { PolicyPlugin } from "./policy/types"; +import { isNativePlugin, validateNativePlugin } from "./policy/types"; import type { ReadinessCriterion, ReadinessContext } from "./readiness"; // ─── Policy configuration types ─── @@ -158,10 +161,39 @@ export function parsePolicySources(raw: string | undefined): string[] | undefine // ─── Loading ─── +/** + * Normalize a native plugin export to ensure `meta.sourceType` and `meta.trust` + * are populated. Plugin authors may omit these fields; we default them here + * so the returned object satisfies the full `PolicyPlugin` type contract. + */ +function normalizeNativePlugin(plugin: PolicyPlugin): PolicyPlugin { + return { + ...plugin, + meta: { + ...plugin.meta, + sourceType: plugin.meta.sourceType ?? "module", + trust: plugin.meta.trust ?? "trusted-code" + } + }; +} + +/** + * Load a policy from a file path or npm specifier. + * + * Returns either a PolicyConfig (for traditional criteria-based policies) + * or a PolicyPlugin (for native plugins that export the full plugin contract). + * Native plugins are detected via `isNativePlugin()`: they must have a `meta` + * object with a non-empty `meta.name` string, and must NOT have a root-level + * `name` string (which would indicate a PolicyConfig). + * + * Native plugin exports may omit `meta.sourceType` and `meta.trust`; + * they are normalised to "module" and "trusted-code" before returning. + * Callers should not access these fields on the raw return value. + */ export async function loadPolicy( source: string, options?: { jsonOnly?: boolean } -): Promise { +): Promise { const jsonOnly = options?.jsonOnly ?? false; // Local file path (relative or absolute) @@ -182,8 +214,17 @@ export async function loadPolicy( ); } try { - const mod = (await import(resolved)) as Record; + // Use pathToFileURL to convert filesystem paths to file:// URLs. + // On Windows, path.resolve() returns paths like C:\... which dynamic + // import() treats as a URL scheme (c:), causing ERR_UNSUPPORTED_ESM_URL_SCHEME. + const mod = (await import(pathToFileURL(resolved).href)) as Record; const config = (mod.default ?? mod) as unknown; + // Native PolicyPlugin exports have a `meta` property instead of a root-level `name`. + // Detect and return them directly without PolicyConfig validation. + if (isNativePlugin(config)) { + validateNativePlugin(config, source); + return normalizeNativePlugin(config); + } return validatePolicyConfig(config, source); } catch (err) { if ( @@ -216,6 +257,11 @@ export async function loadPolicy( try { const mod = (await import(source)) as Record; const config = (mod.default ?? mod) as unknown; + // Native PolicyPlugin exports from npm packages + if (isNativePlugin(config)) { + validateNativePlugin(config, source); + return normalizeNativePlugin(config); + } return validatePolicyConfig(config, source); } catch (err) { const message = diff --git a/packages/core/src/services/policy/index.ts b/packages/core/src/services/policy/index.ts index 4247923..95c375e 100644 --- a/packages/core/src/services/policy/index.ts +++ b/packages/core/src/services/policy/index.ts @@ -19,7 +19,7 @@ export type { EngineReport, Grade } from "./types"; -export { calculateScore } from "./types"; +export { calculateScore, isNativePlugin, validateNativePlugin } from "./types"; export { executePlugins } from "./engine"; export type { EngineOptions } from "./engine"; export { compilePolicyConfig } from "./compiler"; diff --git a/packages/core/src/services/policy/loader.ts b/packages/core/src/services/policy/loader.ts index cc01c61..a5675ad 100644 --- a/packages/core/src/services/policy/loader.ts +++ b/packages/core/src/services/policy/loader.ts @@ -20,6 +20,7 @@ import { compilePolicyConfig } from "./compiler"; import type { CompilationResult } from "./compiler"; import type { EngineOptions } from "./engine"; import type { PolicyPlugin } from "./types"; +import { isNativePlugin } from "./types"; export type LoadedChain = { plugins: PolicyPlugin[]; @@ -80,10 +81,27 @@ export async function loadPluginChain( let passRateThreshold = 0.8; for (const source of policySources) { - const policyConfig: PolicyConfig = await loadPolicy(source, { + const loaded = await loadPolicy(source, { jsonOnly: options?.jsonOnly }); + // Native PolicyPlugin exports — use directly with trusted-code trust. + // These modules export the full plugin contract (detectors, hooks, recommenders) + // instead of the PolicyConfig DSL (criteria.add/disable/override). + if (isNativePlugin(loaded)) { + plugins.push({ + ...loaded, + meta: { + ...loaded.meta, + sourceType: "module", + trust: "trusted-code" + } + }); + continue; + } + + const policyConfig: PolicyConfig = loaded; + // Check if this is a module policy (imperative plugin) with code-level hooks if (isImperativePlugin(policyConfig)) { // Module policies: wrap as trusted-code plugin diff --git a/packages/core/src/services/policy/types.ts b/packages/core/src/services/policy/types.ts index 7acd77b..0439150 100644 --- a/packages/core/src/services/policy/types.ts +++ b/packages/core/src/services/policy/types.ts @@ -157,6 +157,127 @@ export type PolicyPlugin = { onError?: (error: Error, stage: PluginStage, ctx: PolicyContext) => boolean; }; +// ─── Type guards ─── + +/** + * Detect whether a loaded module export is a native PolicyPlugin. + * + * Detection rules: + * 1. Must have a `meta` object with a non-empty `meta.name` string + * 2. Must NOT have a root-level `name` string (which would indicate a PolicyConfig) + * 3. If `meta.sourceType` or `meta.trust` are provided, they must be valid values + * + * Note: This is a detection heuristic, not a full validation. The loader normalises + * `meta.sourceType` and `meta.trust` after detection (overriding with "module" and + * "trusted-code"), so these fields are optional in the module export. + * Use `validateNativePlugin()` after detection to verify the plugin has valid hooks. + */ +export function isNativePlugin(obj: unknown): obj is PolicyPlugin { + if (typeof obj !== "object" || obj === null) return false; + const record = obj as Record; + if (typeof record.meta !== "object" || record.meta === null) return false; + if (typeof record.name === "string") return false; + const meta = record.meta as Record; + if (typeof meta.name !== "string" || meta.name.trim().length === 0) return false; + // Reject if meta fields are present but invalid + if ( + meta.sourceType !== undefined && + !["module", "json", "builtin"].includes(meta.sourceType as string) + ) + return false; + if ( + meta.trust !== undefined && + !["trusted-code", "safe-declarative"].includes(meta.trust as string) + ) + return false; + return true; +} + +/** + * Validate that a native plugin export has the minimum required structure. + * Checks that hooks are the correct types and that detector/recommender arrays + * contain objects with the expected callable members. + * Throws descriptive errors for invalid plugins so issues are caught at load time. + */ +export function validateNativePlugin(obj: PolicyPlugin, source: string): void { + const { meta } = obj; + if (!meta.name?.trim()) { + throw new Error(`Native plugin "${source}" is invalid: meta.name is required`); + } + + // Validate hook functions + if (obj.afterDetect !== undefined && typeof obj.afterDetect !== "function") { + throw new Error(`Native plugin "${source}" is invalid: afterDetect must be a function`); + } + if (obj.beforeRecommend !== undefined && typeof obj.beforeRecommend !== "function") { + throw new Error(`Native plugin "${source}" is invalid: beforeRecommend must be a function`); + } + if (obj.afterRecommend !== undefined && typeof obj.afterRecommend !== "function") { + throw new Error(`Native plugin "${source}" is invalid: afterRecommend must be a function`); + } + if (obj.onError !== undefined && typeof obj.onError !== "function") { + throw new Error(`Native plugin "${source}" is invalid: onError must be a function`); + } + + // Validate detector array members + if (obj.detectors !== undefined) { + if (!Array.isArray(obj.detectors)) { + throw new Error(`Native plugin "${source}" is invalid: detectors must be an array`); + } + for (const [i, d] of obj.detectors.entries()) { + if (typeof d !== "object" || d === null) { + throw new Error(`Native plugin "${source}" is invalid: detectors[${i}] must be an object`); + } + if (typeof d.id !== "string" || !d.id.trim()) { + throw new Error( + `Native plugin "${source}" is invalid: detectors[${i}].id must be a non-empty string` + ); + } + if (typeof d.detect !== "function") { + throw new Error( + `Native plugin "${source}" is invalid: detectors[${i}].detect must be a function` + ); + } + } + } + + // Validate recommender array members + if (obj.recommenders !== undefined) { + if (!Array.isArray(obj.recommenders)) { + throw new Error(`Native plugin "${source}" is invalid: recommenders must be an array`); + } + for (const [i, r] of obj.recommenders.entries()) { + if (typeof r !== "object" || r === null) { + throw new Error( + `Native plugin "${source}" is invalid: recommenders[${i}] must be an object` + ); + } + if (typeof r.id !== "string" || !r.id.trim()) { + throw new Error( + `Native plugin "${source}" is invalid: recommenders[${i}].id must be a non-empty string` + ); + } + if (typeof r.recommend !== "function") { + throw new Error( + `Native plugin "${source}" is invalid: recommenders[${i}].recommend must be a function` + ); + } + } + } + + const hasHooks = + obj.detectors?.length || + obj.afterDetect || + obj.beforeRecommend || + obj.recommenders?.length || + obj.afterRecommend; + if (!hasHooks) { + throw new Error( + `Native plugin "${source}" is invalid: must implement at least one hook (detectors, afterDetect, beforeRecommend, recommenders, or afterRecommend)` + ); + } +} + // ─── Engine output ─── /** Grade label for a readiness score. */ diff --git a/packages/core/src/services/readiness/index.ts b/packages/core/src/services/readiness/index.ts index 9691397..f126d00 100644 --- a/packages/core/src/services/readiness/index.ts +++ b/packages/core/src/services/readiness/index.ts @@ -7,6 +7,7 @@ import { loadPolicy, resolveChain } from "../policy"; import { executePlugins } from "../policy/engine"; import { loadPluginChain } from "../policy/loader"; import type { PolicyContext } from "../policy/types"; +import { isNativePlugin } from "../policy/types"; import { parseVscodeLocations } from "./checkers"; import { buildCriteria } from "./criteria"; @@ -90,14 +91,33 @@ export async function runReadinessReport(options: ReadinessOptions): Promise 0) { + const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs); + resolvedCriteria = resolved.criteria; + resolvedExtras = resolved.extras; + passRateThreshold = resolved.thresholds.passRate; + policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length }; + } else { + resolvedCriteria = baseCriteria; + resolvedExtras = baseExtras; + } + // When native plugins are present, automatically enable the engine path + // so their detectors, hooks, and recommenders execute. + // Use a local copy to avoid mutating the caller's options object. + if (hasNativePlugin && !options.shadow) { + options = { ...options, shadow: true }; } - const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs); - resolvedCriteria = resolved.criteria; - resolvedExtras = resolved.extras; - passRateThreshold = resolved.thresholds.passRate; - policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length }; } else { resolvedCriteria = baseCriteria; resolvedExtras = baseExtras; diff --git a/src/services/__tests__/policy-engine-types.test.ts b/src/services/__tests__/policy-engine-types.test.ts index 1161573..bd41308 100644 --- a/src/services/__tests__/policy-engine-types.test.ts +++ b/src/services/__tests__/policy-engine-types.test.ts @@ -2,13 +2,16 @@ import type { Signal, Recommendation, SignalPatch, - RecommendationPatch + RecommendationPatch, + PolicyPlugin } from "@agentrc/core/services/policy/types"; import { calculateScore, applySignalPatch, applyRecommendationPatch, - resolveSupersedes + resolveSupersedes, + isNativePlugin, + validateNativePlugin } from "@agentrc/core/services/policy/types"; import { describe, expect, it } from "vitest"; @@ -415,3 +418,240 @@ describe("resolveSupersedes", () => { expect(result[0].origin.modifiedBy).toBeUndefined(); }); }); + +// ─── isNativePlugin ─── + +describe("isNativePlugin", () => { + it("returns true for an object with meta.name", () => { + const plugin: PolicyPlugin = { + meta: { name: "test-plugin", sourceType: "module", trust: "trusted-code" }, + detectors: [ + { + id: "d1", + kind: "file", + detect: async () => ({ + id: "s1", + kind: "file" as const, + status: "detected" as const, + label: "S1", + origin: { addedBy: "test" } + }) + } + ] + }; + expect(isNativePlugin(plugin)).toBe(true); + }); + + it("returns false for a PolicyConfig object (has root-level name)", () => { + const config = { name: "my-policy", criteria: { disable: ["readme"] } }; + expect(isNativePlugin(config)).toBe(false); + }); + + it("returns false for null", () => { + expect(isNativePlugin(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isNativePlugin(undefined)).toBe(false); + }); + + it("returns false for a string", () => { + expect(isNativePlugin("not a plugin")).toBe(false); + }); + + it("returns false for an object with meta but no meta.name", () => { + expect(isNativePlugin({ meta: { sourceType: "module" } })).toBe(false); + }); + + it("returns false for an object with meta.name that is empty", () => { + expect(isNativePlugin({ meta: { name: " " } })).toBe(false); + }); + + it("returns false for an object with meta as a non-object", () => { + expect(isNativePlugin({ meta: "not-an-object" })).toBe(false); + }); + + it("returns false for an object with meta as null", () => { + expect(isNativePlugin({ meta: null })).toBe(false); + }); + + it("returns false for an object with invalid meta.sourceType", () => { + expect(isNativePlugin({ meta: { name: "test", sourceType: "invalid" } })).toBe(false); + }); + + it("returns false for an object with invalid meta.trust", () => { + expect(isNativePlugin({ meta: { name: "test", trust: "invalid" } })).toBe(false); + }); + + it("returns true when meta.sourceType and meta.trust are valid", () => { + expect( + isNativePlugin({ meta: { name: "test", sourceType: "module", trust: "trusted-code" } }) + ).toBe(true); + }); + + it("returns true when meta.sourceType and meta.trust are omitted", () => { + expect(isNativePlugin({ meta: { name: "test" } })).toBe(true); + }); +}); + +// ─── validateNativePlugin ─── + +describe("validateNativePlugin", () => { + it("does not throw for a valid native plugin with detectors", () => { + const plugin: PolicyPlugin = { + meta: { name: "valid", sourceType: "module", trust: "trusted-code" }, + detectors: [ + { + id: "d1", + kind: "file", + detect: async () => ({ + id: "s1", + kind: "file" as const, + status: "detected" as const, + label: "S1", + origin: { addedBy: "test" } + }) + } + ] + }; + expect(() => validateNativePlugin(plugin, "test.mjs")).not.toThrow(); + }); + + it("does not throw for a valid native plugin with only afterDetect", () => { + const plugin: PolicyPlugin = { + meta: { name: "hook-only", sourceType: "module", trust: "trusted-code" }, + afterDetect: async () => undefined + }; + expect(() => validateNativePlugin(plugin, "hook.mjs")).not.toThrow(); + }); + + it("does not throw for a valid native plugin with only afterRecommend", () => { + const plugin: PolicyPlugin = { + meta: { name: "rec-hook", sourceType: "module", trust: "trusted-code" }, + afterRecommend: async () => undefined + }; + expect(() => validateNativePlugin(plugin, "rec.mjs")).not.toThrow(); + }); + + it("throws for a plugin with no hooks at all", () => { + const plugin: PolicyPlugin = { + meta: { name: "empty", sourceType: "module", trust: "trusted-code" } + }; + expect(() => validateNativePlugin(plugin, "empty.mjs")).toThrow( + "must implement at least one hook" + ); + }); + + it("throws for a plugin with empty meta.name", () => { + const plugin = { + meta: { name: "", sourceType: "module", trust: "trusted-code" }, + afterDetect: async () => undefined + } as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow("meta.name is required"); + }); + + it("throws for a plugin with afterDetect that is not a function", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + afterDetect: "not a function" + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow("afterDetect must be a function"); + }); + + it("throws for a plugin with beforeRecommend that is not a function", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + beforeRecommend: 42 + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "beforeRecommend must be a function" + ); + }); + + it("throws for a plugin with afterRecommend that is not a function", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + afterRecommend: {} + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "afterRecommend must be a function" + ); + }); + + it("throws for a plugin with detectors that is not an array", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + detectors: "not-an-array" + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow("detectors must be an array"); + }); + + it("throws for a detector entry that is null", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + detectors: [null] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow("detectors[0] must be an object"); + }); + + it("throws for a recommender entry that is a primitive", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + recommenders: ["not-an-object"] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "recommenders[0] must be an object" + ); + }); + + it("throws for a detector missing an id", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + detectors: [ + { + kind: "file", + detect: async () => ({ + id: "s", + kind: "file", + status: "detected", + label: "S", + origin: { addedBy: "t" } + }) + } + ] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "detectors[0].id must be a non-empty string" + ); + }); + + it("throws for a detector with detect that is not a function", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + detectors: [{ id: "d1", kind: "file", detect: "not-a-function" }] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "detectors[0].detect must be a function" + ); + }); + + it("throws for a recommender missing an id", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + recommenders: [{ recommend: async () => [] }] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "recommenders[0].id must be a non-empty string" + ); + }); + + it("throws for a recommender with recommend that is not a function", () => { + const plugin = { + meta: { name: "bad", sourceType: "module", trust: "trusted-code" }, + recommenders: [{ id: "r1", recommend: 42 }] + } as unknown as PolicyPlugin; + expect(() => validateNativePlugin(plugin, "bad.mjs")).toThrow( + "recommenders[0].recommend must be a function" + ); + }); +}); diff --git a/src/services/__tests__/policy-loader.test.ts b/src/services/__tests__/policy-loader.test.ts index 0d418a7..2d814dc 100644 --- a/src/services/__tests__/policy-loader.test.ts +++ b/src/services/__tests__/policy-loader.test.ts @@ -125,3 +125,200 @@ describe("loadPluginChain with JSON policy file", () => { expect(chain.plugins[1].meta.trust).toBe("safe-declarative"); }); }); + +describe("loadPluginChain with native PolicyPlugin module", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-native-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("loads a native PolicyPlugin with afterDetect hook as trusted-code", async () => { + const pluginCode = ` + export default { + meta: { name: "native-hook", sourceType: "module", trust: "trusted-code" }, + afterDetect: async (signals) => ({ + modify: signals + .filter(s => s.id === "readme") + .map(s => ({ id: s.id, changes: { label: "Patched by native" } })) + }) + }; + `; + const pluginPath = path.join(tmpDir, "native-hook.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + const chain = await loadPluginChain([pluginPath]); + expect(chain.plugins).toHaveLength(2); + expect(chain.plugins[0].meta.name).toBe("builtin"); + expect(chain.plugins[1].meta.name).toBe("native-hook"); + expect(chain.plugins[1].meta.sourceType).toBe("module"); + expect(chain.plugins[1].meta.trust).toBe("trusted-code"); + expect(chain.plugins[1].afterDetect).toBeDefined(); + }); + + it("normalizes meta.sourceType and meta.trust when omitted by native plugin", async () => { + const pluginCode = ` + export default { + meta: { name: "minimal-native" }, + afterDetect: async () => undefined + }; + `; + const pluginPath = path.join(tmpDir, "minimal-native.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + const chain = await loadPluginChain([pluginPath]); + expect(chain.plugins).toHaveLength(2); + expect(chain.plugins[1].meta.name).toBe("minimal-native"); + expect(chain.plugins[1].meta.sourceType).toBe("module"); + expect(chain.plugins[1].meta.trust).toBe("trusted-code"); + }); + + it("loads a native PolicyPlugin with detectors and recommenders", async () => { + const pluginCode = ` + export default { + meta: { name: "native-full", sourceType: "module", trust: "trusted-code" }, + detectors: [{ + id: "custom-check", + kind: "custom", + detect: async (ctx) => ({ + id: "custom-signal", + kind: "custom", + status: "detected", + label: "Custom detection", + origin: { addedBy: "native-full" } + }) + }], + recommenders: [{ + id: "custom-rec", + recommend: async (signals) => { + const s = signals.find(s => s.id === "custom-signal"); + if (!s) return []; + return { + id: "custom-fix", + signalId: "custom-signal", + impact: "high", + message: "Fix this custom issue", + origin: { addedBy: "native-full" } + }; + } + }] + }; + `; + const pluginPath = path.join(tmpDir, "native-full.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + const chain = await loadPluginChain([pluginPath]); + expect(chain.plugins).toHaveLength(2); + expect(chain.plugins[1].detectors).toHaveLength(1); + expect(chain.plugins[1].recommenders).toHaveLength(1); + }); + + it("executes native plugin hooks through the engine pipeline", async () => { + const pluginCode = ` + export default { + meta: { name: "engine-test", sourceType: "module", trust: "trusted-code" }, + detectors: [{ + id: "native-detector", + kind: "custom", + detect: async () => ({ + id: "native-signal", + kind: "custom", + status: "detected", + label: "Native", + origin: { addedBy: "engine-test" } + }) + }], + afterDetect: async (signals) => ({ + modify: signals + .filter(s => s.id === "native-signal") + .map(s => ({ id: s.id, changes: { label: "Patched", metadata: { patched: true } } })) + }), + recommenders: [{ + id: "native-recommender", + recommend: async (signals) => { + const s = signals.find(s => s.id === "native-signal"); + if (!s || !s.metadata?.patched) return []; + return { + id: "native-rec", + signalId: "native-signal", + impact: "medium", + message: "Native recommendation after patch", + origin: { addedBy: "engine-test" } + }; + } + }] + }; + `; + const pluginPath = path.join(tmpDir, "engine-test.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + const chain = await loadPluginChain([pluginPath]); + const report = await executePlugins(chain.plugins, makeCtx()); + + const nativeSignal = report.signals.find((s) => s.id === "native-signal"); + expect(nativeSignal).toBeDefined(); + expect(nativeSignal!.label).toBe("Patched"); + expect(nativeSignal!.metadata?.patched).toBe(true); + + const nativeRec = report.recommendations.find((r) => r.id === "native-rec"); + expect(nativeRec).toBeDefined(); + expect(nativeRec!.message).toBe("Native recommendation after patch"); + }); + + it("rejects native plugin with no hooks", async () => { + const pluginCode = ` + export default { + meta: { name: "empty-plugin", sourceType: "module", trust: "trusted-code" } + }; + `; + const pluginPath = path.join(tmpDir, "empty.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + await expect(loadPluginChain([pluginPath])).rejects.toThrow("must implement at least one hook"); + }); + + it("forces sourceType to module and trust to trusted-code regardless of export values", async () => { + const pluginCode = ` + export default { + meta: { name: "override-test", sourceType: "json", trust: "safe-declarative" }, + afterDetect: async () => undefined + }; + `; + const pluginPath = path.join(tmpDir, "override.mjs"); + await fs.writeFile(pluginPath, pluginCode); + + const chain = await loadPluginChain([pluginPath]); + // Loader overrides sourceType and trust for security + expect(chain.plugins[1].meta.sourceType).toBe("module"); + expect(chain.plugins[1].meta.trust).toBe("trusted-code"); + }); + + it("loads native plugin alongside a JSON policy", async () => { + const jsonPath = path.join(tmpDir, "config.json"); + await fs.writeFile( + jsonPath, + JSON.stringify({ name: "json-policy", criteria: { disable: ["readme"] } }) + ); + + const nativeCode = ` + export default { + meta: { name: "native-policy", sourceType: "module", trust: "trusted-code" }, + afterDetect: async () => undefined + }; + `; + const nativePath = path.join(tmpDir, "native.mjs"); + await fs.writeFile(nativePath, nativeCode); + + const chain = await loadPluginChain([jsonPath, nativePath]); + expect(chain.plugins).toHaveLength(3); + expect(chain.plugins[0].meta.name).toBe("builtin"); + expect(chain.plugins[1].meta.name).toBe("json-policy"); + expect(chain.plugins[1].meta.trust).toBe("safe-declarative"); + expect(chain.plugins[2].meta.name).toBe("native-policy"); + expect(chain.plugins[2].meta.trust).toBe("trusted-code"); + }); +}); diff --git a/src/services/__tests__/policy.test.ts b/src/services/__tests__/policy.test.ts index 6697eb9..b35e9a0 100644 --- a/src/services/__tests__/policy.test.ts +++ b/src/services/__tests__/policy.test.ts @@ -4,9 +4,22 @@ import path from "path"; import type { ExtraDefinition, PolicyConfig } from "@agentrc/core/services/policy"; import { loadPolicy, resolveChain, parsePolicySources } from "@agentrc/core/services/policy"; +import { isNativePlugin } from "@agentrc/core/services/policy/types"; import type { ReadinessCriterion } from "@agentrc/core/services/readiness"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +/** Helper: load a policy and assert it is a PolicyConfig (not a native plugin). */ +async function loadPolicyConfig( + source: string, + options?: { jsonOnly?: boolean } +): Promise { + const result = await loadPolicy(source, options); + if (isNativePlugin(result)) { + throw new Error(`Expected PolicyConfig but got native PolicyPlugin from "${source}"`); + } + return result; +} + // ─── Helpers ─── function makeCriterion( @@ -240,14 +253,14 @@ describe("loadPolicy", () => { thresholds: { passRate: 0.9 } }); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.name).toBe("my-policy"); expect(config.thresholds?.passRate).toBe(0.9); }); it("loads a minimal JSON policy (name only)", async () => { const filePath = await writePolicy("minimal.json", { name: "minimal" }); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.name).toBe("minimal"); }); @@ -257,7 +270,7 @@ describe("loadPolicy", () => { criteria: { disable: ["lint-config", "readme"] } }); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.criteria?.disable).toEqual(["lint-config", "readme"]); }); @@ -383,7 +396,7 @@ describe("loadPolicy", () => { it("loads JSON policy via absolute path", async () => { const filePath = path.join(tmpDir, "abs-policy.json"); await fs.writeFile(filePath, JSON.stringify({ name: "absolute" }), "utf8"); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.name).toBe("absolute"); }); @@ -419,7 +432,7 @@ describe("loadPolicy", () => { } } }); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.criteria?.override?.a).toEqual({ title: "New", pillar: "testing", @@ -433,7 +446,7 @@ describe("loadPolicy", () => { it("loads a .mjs module policy", async () => { const filePath = path.join(tmpDir, "mod-policy.mjs"); await fs.writeFile(filePath, `export default { name: "mjs-policy", criteria: {} };\n`, "utf8"); - const config = await loadPolicy(filePath); + const config = await loadPolicyConfig(filePath); expect(config.name).toBe("mjs-policy"); }); }); @@ -454,7 +467,7 @@ describe("loadPolicy jsonOnly", () => { it("allows JSON policies when jsonOnly is true", async () => { const filePath = path.join(tmpDir, "ok.json"); await fs.writeFile(filePath, JSON.stringify({ name: "ok" }), "utf8"); - const config = await loadPolicy(filePath, { jsonOnly: true }); + const config = await loadPolicyConfig(filePath, { jsonOnly: true }); expect(config.name).toBe("ok"); });