feat(coding-agent): add project trust gating

This commit is contained in:
Mario Zechner
2026-06-05 10:51:20 +02:00
parent db594d3a59
commit 89a92207f1
28 changed files with 1029 additions and 112 deletions
@@ -159,6 +159,10 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde
export type SettingsScope = "global" | "project";
export interface SettingsManagerCreateOptions {
projectTrusted?: boolean;
}
export interface SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
}
@@ -259,6 +263,7 @@ export class SettingsManager {
private globalSettings: Settings;
private projectSettings: Settings;
private settings: Settings;
private projectTrusted: boolean;
private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications
private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session
@@ -275,10 +280,12 @@ export class SettingsManager {
globalLoadError: Error | null = null,
projectLoadError: Error | null = null,
initialErrors: SettingsError[] = [],
projectTrusted = true,
) {
this.storage = storage;
this.globalSettings = initialGlobal;
this.projectSettings = initialProject;
this.projectTrusted = projectTrusted;
this.globalSettingsLoadError = globalLoadError;
this.projectSettingsLoadError = projectLoadError;
this.errors = [...initialErrors];
@@ -286,15 +293,20 @@ export class SettingsManager {
}
/** Create a SettingsManager that loads from files */
static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager {
static create(
cwd: string,
agentDir: string = getAgentDir(),
options: SettingsManagerCreateOptions = {},
): SettingsManager {
const storage = new FileSettingsStorage(cwd, agentDir);
return SettingsManager.fromStorage(storage);
return SettingsManager.fromStorage(storage, options);
}
/** Create a SettingsManager from an arbitrary storage backend */
static fromStorage(storage: SettingsStorage): SettingsManager {
static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager {
const projectTrusted = options.projectTrusted ?? true;
const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted);
const initialErrors: SettingsError[] = [];
if (globalLoad.error) {
initialErrors.push({ scope: "global", error: globalLoad.error });
@@ -310,6 +322,7 @@ export class SettingsManager {
globalLoad.error,
projectLoad.error,
initialErrors,
projectTrusted,
);
}
@@ -321,7 +334,11 @@ export class SettingsManager {
return SettingsManager.fromStorage(storage);
}
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings {
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
if (scope === "project" && !projectTrusted) {
return {};
}
let content: string | undefined;
storage.withLock(scope, (current) => {
content = current;
@@ -338,9 +355,10 @@ export class SettingsManager {
private static tryLoadFromStorage(
storage: SettingsStorage,
scope: SettingsScope,
projectTrusted = true,
): { settings: Settings; error: Error | null } {
try {
return { settings: SettingsManager.loadFromStorage(storage, scope), error: null };
return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null };
} catch (error) {
return { settings: {}, error: error as Error };
}
@@ -416,6 +434,35 @@ export class SettingsManager {
return structuredClone(this.projectSettings);
}
isProjectTrusted(): boolean {
return this.projectTrusted;
}
setProjectTrusted(trusted: boolean): void {
if (this.projectTrusted === trusted) {
return;
}
this.projectTrusted = trusted;
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
if (!trusted) {
this.projectSettings = {};
this.projectSettingsLoadError = null;
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
return;
}
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted);
this.projectSettings = projectLoad.settings;
this.projectSettingsLoadError = projectLoad.error;
if (projectLoad.error) {
this.recordError("project", projectLoad.error);
}
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
}
async reload(): Promise<void> {
await this.writeQueue;
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
@@ -432,7 +479,7 @@ export class SettingsManager {
this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear();
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project");
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted);
if (!projectLoad.error) {
this.projectSettings = projectLoad.settings;
this.projectSettingsLoadError = null;
@@ -471,6 +518,12 @@ export class SettingsManager {
}
}
private assertProjectTrustedForWrite(): void {
if (!this.projectTrusted) {
throw new Error("Project is not trusted; refusing to write project settings");
}
}
private recordError(scope: SettingsScope, error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push({ scope, error: normalizedError });
@@ -490,6 +543,9 @@ export class SettingsManager {
private enqueueWrite(scope: SettingsScope, task: () => void): void {
this.writeQueue = this.writeQueue
.then(() => {
if (scope === "project") {
this.assertProjectTrustedForWrite();
}
task();
this.clearModifiedScope(scope);
})
@@ -554,6 +610,7 @@ export class SettingsManager {
}
private saveProjectSettings(settings: Settings): void {
this.assertProjectTrustedForWrite();
this.projectSettings = structuredClone(settings);
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
@@ -569,6 +626,14 @@ export class SettingsManager {
});
}
private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void {
this.assertProjectTrustedForWrite();
const projectSettings = structuredClone(this.projectSettings);
update(projectSettings);
this.markProjectModified(field);
this.saveProjectSettings(projectSettings);
}
async flush(): Promise<void> {
await this.writeQueue;
}
@@ -839,10 +904,9 @@ export class SettingsManager {
}
setProjectPackages(packages: PackageSource[]): void {
const projectSettings = structuredClone(this.projectSettings);
projectSettings.packages = packages;
this.markProjectModified("packages");
this.saveProjectSettings(projectSettings);
this.updateProjectSettings("packages", (settings) => {
settings.packages = packages;
});
}
getExtensionPaths(): string[] {
@@ -856,10 +920,9 @@ export class SettingsManager {
}
setProjectExtensionPaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings);
projectSettings.extensions = paths;
this.markProjectModified("extensions");
this.saveProjectSettings(projectSettings);
this.updateProjectSettings("extensions", (settings) => {
settings.extensions = paths;
});
}
getSkillPaths(): string[] {
@@ -873,10 +936,9 @@ export class SettingsManager {
}
setProjectSkillPaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings);
projectSettings.skills = paths;
this.markProjectModified("skills");
this.saveProjectSettings(projectSettings);
this.updateProjectSettings("skills", (settings) => {
settings.skills = paths;
});
}
getPromptTemplatePaths(): string[] {
@@ -890,10 +952,9 @@ export class SettingsManager {
}
setProjectPromptTemplatePaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings);
projectSettings.prompts = paths;
this.markProjectModified("prompts");
this.saveProjectSettings(projectSettings);
this.updateProjectSettings("prompts", (settings) => {
settings.prompts = paths;
});
}
getThemePaths(): string[] {
@@ -907,10 +968,9 @@ export class SettingsManager {
}
setProjectThemePaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings);
projectSettings.themes = paths;
this.markProjectModified("themes");
this.saveProjectSettings(projectSettings);
this.updateProjectSettings("themes", (settings) => {
settings.themes = paths;
});
}
getEnableSkillCommands(): boolean {