feat(coding-agent): add extension project trust decisions

This commit is contained in:
Mario Zechner
2026-06-08 12:58:49 +02:00
parent dce3e28516
commit 718215bd95
18 changed files with 631 additions and 86 deletions

View File

@@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts";
@@ -25,6 +25,10 @@ export interface ResourceExtensionPaths {
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
}
export interface ResourceLoaderReloadOptions {
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
}
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
@@ -34,7 +38,7 @@ export interface ResourceLoader {
getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[];
extendResources(paths: ResourceExtensionPaths): void;
reload(): Promise<void>;
reload(options?: ResourceLoaderReloadOptions): Promise<void>;
}
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
@@ -321,7 +325,19 @@ export class DefaultResourceLoader implements ResourceLoader {
}
}
async reload(): Promise<void> {
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
// Force untrusted project settings for the bootstrap pass. This keeps project-local
// extensions/packages out while still loading user/global and temporary CLI extensions.
this.settingsManager.setProjectTrusted(false);
await this.settingsManager.reload();
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
this.settingsManager.setProjectTrusted(projectTrusted);
}
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.
await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
@@ -334,9 +350,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map();
// Helper to extract enabled paths and store metadata
const getEnabledResources = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {
for (const r of resources) {
if (!metadataByPath.has(r.path)) {
metadataByPath.set(r.path, r.metadata);
@@ -345,37 +359,14 @@ export class DefaultResourceLoader implements ResourceLoader {
return resources.filter((r) => r.enabled);
};
const getEnabledPaths = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): string[] => getEnabledResources(resources).map((r) => r.path);
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>
getEnabledResources(resources).map((r) => r.path);
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
const enabledThemes = getEnabledPaths(resolvedPaths.themes);
const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
return resource.path;
}
try {
const stats = statSync(resource.path);
if (!stats.isDirectory()) {
return resource.path;
}
} catch {
return resource.path;
}
const skillFile = join(resource.path, "SKILL.md");
if (existsSync(skillFile)) {
if (!metadataByPath.has(skillFile)) {
metadataByPath.set(skillFile, resource.metadata);
}
return skillFile;
}
return resource.path;
};
const enabledSkills = enabledSkillResources.map(mapSkillPath);
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
// Add CLI paths metadata
for (const r of cliExtensionPaths.extensions) {
@@ -398,18 +389,7 @@ export class DefaultResourceLoader implements ResourceLoader {
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);
for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
@@ -497,6 +477,115 @@ export class DefaultResourceLoader implements ResourceLoader {
: baseAppend;
}
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
});
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);
const extensionPaths = this.noExtensions
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
if (!options.includeInlineFactories) {
return extensionsResult;
}
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
return extensionsResult;
}
private resolveExtensionLoadPath(path: string): string {
return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
}
private async loadFinalExtensionSet(
extensionPaths: string[],
preTrustExtensions: LoadExtensionsResult | undefined,
): Promise<LoadExtensionsResult> {
if (!preTrustExtensions) {
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
this.addExtensionConflictDiagnostics(extensionsResult);
return extensionsResult;
}
const preloadedByPath = new Map(
preTrustExtensions.extensions
.filter((extension) => !extension.path.startsWith("<inline:"))
.map((extension) => [extension.resolvedPath, extension]),
);
const failedPreloadPaths = new Set(
preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)),
);
const remainingPaths = extensionPaths.filter((path) => {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
});
const remainingExtensions = await loadExtensions(
remainingPaths,
this.cwd,
this.eventBus,
preTrustExtensions.runtime,
);
const loadedByPath = new Map(preloadedByPath);
for (const extension of remainingExtensions.extensions) {
loadedByPath.set(extension.resolvedPath, extension);
}
const inlineExtensions = preTrustExtensions.extensions.filter((extension) =>
extension.path.startsWith("<inline:"),
);
const orderedExtensions = extensionPaths
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
.filter((extension): extension is Extension => extension !== undefined);
orderedExtensions.push(...inlineExtensions);
const extensionsResult: LoadExtensionsResult = {
extensions: orderedExtensions,
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
runtime: preTrustExtensions.runtime,
};
this.addExtensionConflictDiagnostics(extensionsResult);
return extensionsResult;
}
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
}
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {
return resource.path;
}
try {
const stats = statSync(resource.path);
if (!stats.isDirectory()) {
return resource.path;
}
} catch {
return resource.path;
}
const skillFile = join(resource.path, "SKILL.md");
if (existsSync(skillFile)) {
if (!metadataByPath.has(skillFile)) {
metadataByPath.set(skillFile, resource.metadata);
}
return skillFile;
}
return resource.path;
}
private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> {