feat(coding-agent): add project trust gating
This commit is contained in:
@@ -963,6 +963,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
await this.withProgress("install", source, `Installing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.installNpm(parsed, scope, false);
|
||||
@@ -991,6 +992,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async remove(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.uninstallNpm(parsed, scope);
|
||||
@@ -1673,6 +1675,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
private assertProjectTrustedForScope(scope: SourceScope): void {
|
||||
if (scope === "project" && !this.settingsManager.isProjectTrusted()) {
|
||||
throw new Error("Project is not trusted; refusing to access project package storage");
|
||||
}
|
||||
}
|
||||
|
||||
private getNpmCommand(): { command: string; args: string[] } {
|
||||
const configuredCommand = this.settingsManager.getNpmCommand();
|
||||
if (!configuredCommand || configuredCommand.length === 0) {
|
||||
@@ -1893,6 +1901,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return this.getTemporaryDir("npm");
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm");
|
||||
}
|
||||
return join(this.agentDir, "npm");
|
||||
@@ -1933,6 +1942,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
|
||||
}
|
||||
return join(this.agentDir, "npm", "node_modules", source.name);
|
||||
@@ -1971,6 +1981,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return undefined;
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "git");
|
||||
}
|
||||
return join(this.agentDir, "git");
|
||||
@@ -1996,6 +2007,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
private getBaseDirForScope(scope: SourceScope): string {
|
||||
if (scope === "project") {
|
||||
this.assertProjectTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME);
|
||||
}
|
||||
if (scope === "user") {
|
||||
@@ -2257,9 +2269,10 @@ export class DefaultPackageManager implements PackageManager {
|
||||
themes: join(projectBaseDir, "themes"),
|
||||
};
|
||||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter(
|
||||
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir),
|
||||
);
|
||||
const projectTrusted = this.settingsManager.isProjectTrusted();
|
||||
const projectAgentsSkillDirs = projectTrusted
|
||||
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
|
||||
: [];
|
||||
|
||||
const addResources = (
|
||||
resourceType: ResourceType,
|
||||
@@ -2275,23 +2288,25 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Project extensions from .pi/
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
if (projectTrusted) {
|
||||
// Project extensions from .pi/
|
||||
addResources(
|
||||
"extensions",
|
||||
collectAutoExtensionEntries(projectDirs.extensions),
|
||||
projectMetadata,
|
||||
projectOverrides.extensions,
|
||||
projectBaseDir,
|
||||
);
|
||||
|
||||
// Project skills from .pi/
|
||||
addResources(
|
||||
"skills",
|
||||
collectAutoSkillEntries(projectDirs.skills, "pi"),
|
||||
projectMetadata,
|
||||
projectOverrides.skills,
|
||||
projectBaseDir,
|
||||
);
|
||||
// Project skills from .pi/
|
||||
addResources(
|
||||
"skills",
|
||||
collectAutoSkillEntries(projectDirs.skills, "pi"),
|
||||
projectMetadata,
|
||||
projectOverrides.skills,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
// Project skills from .agents/ (each with its own baseDir)
|
||||
for (const agentsSkillsDir of projectAgentsSkillDirs) {
|
||||
@@ -2309,20 +2324,22 @@ export class DefaultPackageManager implements PackageManager {
|
||||
);
|
||||
}
|
||||
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
if (projectTrusted) {
|
||||
addResources(
|
||||
"prompts",
|
||||
collectAutoPromptEntries(projectDirs.prompts),
|
||||
projectMetadata,
|
||||
projectOverrides.prompts,
|
||||
projectBaseDir,
|
||||
);
|
||||
addResources(
|
||||
"themes",
|
||||
collectAutoThemeEntries(projectDirs.themes),
|
||||
projectMetadata,
|
||||
projectOverrides.themes,
|
||||
projectBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
// User extensions from ~/.pi/agent/
|
||||
addResources(
|
||||
|
||||
@@ -75,6 +75,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
||||
export function loadProjectContextFiles(options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
projectTrusted?: boolean;
|
||||
}): Array<{ path: string; content: string }> {
|
||||
const resolvedCwd = resolvePath(options.cwd);
|
||||
const resolvedAgentDir = resolvePath(options.agentDir);
|
||||
@@ -88,27 +89,29 @@ export function loadProjectContextFiles(options: {
|
||||
seenPaths.add(globalContext.path);
|
||||
}
|
||||
|
||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||
if (options.projectTrusted !== false) {
|
||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||
|
||||
let currentDir = resolvedCwd;
|
||||
const root = resolve("/");
|
||||
let currentDir = resolvedCwd;
|
||||
const root = resolve("/");
|
||||
|
||||
while (true) {
|
||||
const contextFile = loadContextFileFromDir(currentDir);
|
||||
if (contextFile && !seenPaths.has(contextFile.path)) {
|
||||
ancestorContextFiles.unshift(contextFile);
|
||||
seenPaths.add(contextFile.path);
|
||||
while (true) {
|
||||
const contextFile = loadContextFileFromDir(currentDir);
|
||||
if (contextFile && !seenPaths.has(contextFile.path)) {
|
||||
ancestorContextFiles.unshift(contextFile);
|
||||
seenPaths.add(contextFile.path);
|
||||
}
|
||||
|
||||
if (currentDir === root) break;
|
||||
|
||||
const parentDir = resolve(currentDir, "..");
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
if (currentDir === root) break;
|
||||
|
||||
const parentDir = resolve(currentDir, "..");
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
contextFiles.push(...ancestorContextFiles);
|
||||
}
|
||||
|
||||
contextFiles.push(...ancestorContextFiles);
|
||||
|
||||
return contextFiles;
|
||||
}
|
||||
|
||||
@@ -466,7 +469,13 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}
|
||||
|
||||
const agentsFiles = {
|
||||
agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }),
|
||||
agentsFiles: this.noContextFiles
|
||||
? []
|
||||
: loadProjectContextFiles({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
projectTrusted: this.settingsManager.isProjectTrusted(),
|
||||
}),
|
||||
};
|
||||
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
|
||||
this.agentsFiles = resolvedAgentsFiles.agentsFiles;
|
||||
@@ -852,7 +861,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
|
||||
private discoverSystemPromptFile(): string | undefined {
|
||||
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
|
||||
if (existsSync(projectPath)) {
|
||||
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
@@ -866,7 +875,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
|
||||
private discoverAppendSystemPromptFile(): string | undefined {
|
||||
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
|
||||
if (existsSync(projectPath)) {
|
||||
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
|
||||
{ name: "fork", description: "Create a new fork from a previous user message" },
|
||||
{ name: "clone", description: "Duplicate the current session at the current position" },
|
||||
{ name: "tree", description: "Navigate session tree (switch branches)" },
|
||||
{ name: "trust", description: "Save project trust decision for future sessions" },
|
||||
{ name: "login", description: "Configure provider authentication" },
|
||||
{ name: "logout", description: "Remove provider authentication" },
|
||||
{ name: "new", description: "Start a new session" },
|
||||
|
||||
153
packages/coding-agent/src/core/trust-manager.ts
Normal file
153
packages/coding-agent/src/core/trust-manager.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { CONFIG_DIR_NAME } from "../config.ts";
|
||||
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
||||
|
||||
export type ProjectTrustDecision = boolean | null;
|
||||
|
||||
type TrustFile = Record<string, boolean | null | undefined>;
|
||||
|
||||
const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
return canonicalizePath(resolvePath(cwd));
|
||||
}
|
||||
|
||||
function readTrustFile(path: string): TrustFile {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to read trust store ${path}: ${message}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid trust store ${path}: expected an object`);
|
||||
}
|
||||
|
||||
const data: TrustFile = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (value !== true && value !== false && value !== null) {
|
||||
throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`);
|
||||
}
|
||||
data[key] = value;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function writeTrustFile(path: string, data: TrustFile): void {
|
||||
const sorted: TrustFile = {};
|
||||
for (const key of Object.keys(data).sort()) {
|
||||
const value = data[key];
|
||||
if (value === true || value === false || value === null) {
|
||||
sorted[key] = value;
|
||||
}
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function acquireTrustLockSync(path: string): () => void {
|
||||
const trustDir = dirname(path);
|
||||
mkdirSync(trustDir, { recursive: true });
|
||||
const maxAttempts = 10;
|
||||
const delayMs = 20;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` });
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: undefined;
|
||||
if (code !== "ELOCKED" || attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < delayMs) {
|
||||
// Sleep synchronously to avoid changing trust store callers to async.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error("Failed to acquire trust store lock");
|
||||
}
|
||||
|
||||
function withTrustFileLock<T>(path: string, fn: () => T): T {
|
||||
const release = acquireTrustLockSync(path);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
export function hasProjectTrustInputs(cwd: string): boolean {
|
||||
let currentDir = resolvePath(cwd);
|
||||
if (existsSync(join(currentDir, CONFIG_DIR_NAME))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const root = resolve("/");
|
||||
while (true) {
|
||||
for (const filename of CONTEXT_FILE_NAMES) {
|
||||
if (existsSync(join(currentDir, filename))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (existsSync(join(currentDir, ".agents", "skills"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentDir === root) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentDir = resolve(currentDir, "..");
|
||||
if (parentDir === currentDir) {
|
||||
return false;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectTrustStore {
|
||||
private trustPath: string;
|
||||
|
||||
constructor(agentDir: string) {
|
||||
this.trustPath = join(resolvePath(agentDir), "trust.json");
|
||||
}
|
||||
|
||||
get(cwd: string): ProjectTrustDecision {
|
||||
return withTrustFileLock(this.trustPath, () => {
|
||||
const data = readTrustFile(this.trustPath);
|
||||
const value = data[normalizeCwd(cwd)];
|
||||
return value === true || value === false ? value : null;
|
||||
});
|
||||
}
|
||||
|
||||
set(cwd: string, decision: ProjectTrustDecision): void {
|
||||
withTrustFileLock(this.trustPath, () => {
|
||||
const data = readTrustFile(this.trustPath);
|
||||
const key = normalizeCwd(cwd);
|
||||
if (decision === null) {
|
||||
delete data[key];
|
||||
} else {
|
||||
data[key] = decision;
|
||||
}
|
||||
writeTrustFile(this.trustPath, data);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user