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

@@ -3,7 +3,12 @@ import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
import type {
ProjectTrustContext,
ReplacedSessionContext,
SessionShutdownEvent,
SessionStartEvent,
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts";
@@ -32,6 +37,7 @@ export type CreateAgentSessionRuntimeFactory = (options: {
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;
/**
@@ -186,7 +192,11 @@ export class AgentSessionRuntime {
async switchSession(
sessionPath: string,
options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
options?: {
cwdOverride?: string;
withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
projectTrustContextFactory?: (cwd: string) => ProjectTrustContext;
},
): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) {
@@ -203,6 +213,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
}),
);
await this.finishSessionReplacement(options?.withSession);

View File

@@ -6,7 +6,12 @@ import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
import {
DefaultResourceLoader,
type DefaultResourceLoaderOptions,
type ResourceLoader,
type ResourceLoaderReloadOptions,
} from "./resource-loader.ts";
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
import type { SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
@@ -38,6 +43,7 @@ export interface CreateAgentSessionServicesOptions {
modelRegistry?: ModelRegistry;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
}
/**
@@ -142,7 +148,7 @@ export async function createAgentSessionServices(
agentDir,
settingsManager,
});
await resourceLoader.reload();
await resourceLoader.reload(options.resourceLoaderReloadOptions);
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();

View File

@@ -98,6 +98,10 @@ export type {
MessageUpdateEvent,
ModelSelectEvent,
ModelSelectSource,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProjectTrustHandler,
// Provider Registration
ProviderConfig,
ProviderModelConfig,

View File

@@ -410,15 +410,20 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*/
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
export async function loadExtensions(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
const resolvedRuntime = runtime ?? createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
if (error) {
errors.push({ path: extPath, error });
@@ -433,7 +438,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
return {
extensions,
errors,
runtime,
runtime: resolvedRuntime,
};
}

View File

@@ -35,9 +35,13 @@ import type {
InputEvent,
InputEventResult,
InputSource,
LoadExtensionsResult,
MessageEndEvent,
MessageEndEventResult,
MessageRenderer,
ProjectTrustContext,
ProjectTrustEvent,
ProjectTrustEventResult,
ProviderConfig,
RegisteredCommand,
RegisteredTool,
@@ -116,6 +120,7 @@ interface BeforeAgentStartCombinedResult {
type RunnerEmitEvent = Exclude<
ExtensionEvent,
| ToolCallEvent
| ProjectTrustEvent
| ToolResultEvent
| UserBashEvent
| ContextEvent
@@ -189,6 +194,35 @@ export async function emitSessionShutdownEvent(
return false;
}
export async function emitProjectTrustEvent(
extensionsResult: LoadExtensionsResult,
event: ProjectTrustEvent,
ctx: ProjectTrustContext,
): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> {
const errors: ExtensionError[] = [];
for (const ext of extensionsResult.extensions) {
// A single extension may register multiple handlers for the same event.
// The first project_trust handler that returns a decision wins.
const handlers = ext.handlers.get("project_trust");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
return { result: handlerResult, errors };
} catch (error) {
errors.push({
extensionPath: ext.path,
event: event.type,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
}
return { errors };
}
const noOpUIContext: ExtensionUIContext = {
select: async () => undefined,
confirm: async () => false,

View File

@@ -495,9 +495,31 @@ export function defineTool<TParams extends TSchema, TDetails = unknown, TState =
}
// ============================================================================
// Resource Events
// Startup/Resource Events
// ============================================================================
export interface ProjectTrustEvent {
type: "project_trust";
cwd: string;
}
export interface ProjectTrustEventResult {
trusted: boolean;
remember?: boolean;
}
export interface ProjectTrustContext {
cwd: string;
mode: ExtensionMode;
hasUI: boolean;
ui: Pick<ExtensionUIContext, "select" | "confirm" | "input" | "notify">;
}
export type ProjectTrustHandler = (
event: ProjectTrustEvent,
ctx: ProjectTrustContext,
) => Promise<ProjectTrustEventResult> | ProjectTrustEventResult;
/** Fired after session_start to allow extensions to provide additional resource paths. */
export interface ResourcesDiscoverEvent {
type: "resources_discover";
@@ -957,6 +979,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
/** Union of all event types */
export type ExtensionEvent =
| ProjectTrustEvent
| ResourcesDiscoverEvent
| SessionEvent
| ContextEvent
@@ -1095,6 +1118,7 @@ export interface ExtensionAPI {
// Event Subscription
// =========================================================================
on(event: "project_trust", handler: ProjectTrustHandler): void;
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(

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 }> {