Skip to content
Open
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
50 changes: 48 additions & 2 deletions packages/core/src/services/policy.ts
Original file line number Diff line number Diff line change
@@ -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 ───
Expand Down Expand Up @@ -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<PolicyConfig> {
): Promise<PolicyConfig | PolicyPlugin> {
Comment thread
yxbh marked this conversation as resolved.
const jsonOnly = options?.jsonOnly ?? false;

// Local file path (relative or absolute)
Expand All @@ -182,8 +214,17 @@ export async function loadPolicy(
);
}
try {
const mod = (await import(resolved)) as Record<string, unknown>;
// 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<string, unknown>;
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 (
Expand Down Expand Up @@ -216,6 +257,11 @@ export async function loadPolicy(
try {
const mod = (await import(source)) as Record<string, unknown>;
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 =
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/services/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/services/policy/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions packages/core/src/services/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
if (typeof record.meta !== "object" || record.meta === null) return false;
if (typeof record.name === "string") return false;
const meta = record.meta as Record<string, unknown>;
if (typeof meta.name !== "string" || meta.name.trim().length === 0) return false;
Comment thread
yxbh marked this conversation as resolved.
// 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. */
Expand Down
32 changes: 26 additions & 6 deletions packages/core/src/services/readiness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,14 +91,33 @@ export async function runReadinessReport(options: ReadinessOptions): Promise<Rea

if (policySources?.length) {
const policyConfigs: PolicyConfig[] = [];
let hasNativePlugin = false;
for (const source of policySources) {
policyConfigs.push(await loadPolicy(source, { jsonOnly: isConfigSourced }));
const loaded = await loadPolicy(source, { jsonOnly: isConfigSourced });
// Native PolicyPlugin exports are handled by the engine path (loadPluginChain).
// Skip them here — they'll be loaded by loadPluginChain below.
if (isNativePlugin(loaded)) {
hasNativePlugin = true;
continue;
}
policyConfigs.push(loaded);
}
if (policyConfigs.length > 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;
Expand Down
Loading
Loading