feat(coding-agent): add project trust gating
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user