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

View File

@@ -46,6 +46,7 @@ export interface Args {
listModels?: string | true;
offline?: boolean;
verbose?: boolean;
projectTrustOverride?: boolean;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */
@@ -176,6 +177,10 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg === "--verbose") {
result.verbose = true;
} else if (arg === "--approve" || arg === "-a") {
result.projectTrustOverride = true;
} else if (arg === "--no-approve" || arg === "-na") {
result.projectTrustOverride = false;
} else if (arg === "--offline") {
result.offline = true;
} else if (arg.startsWith("@")) {
@@ -225,8 +230,10 @@ ${chalk.bold("Commands:")}
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} list [--approve|--no-approve]
List installed extensions from settings
${APP_NAME} config [--no-approve]
Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
${chalk.bold("Options:")}
@@ -266,6 +273,8 @@ ${chalk.bold("Options:")}
--export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search)
--verbose Force verbose startup (overrides quietStartup setting)
--approve, -a Trust project-local files for this run
--no-approve, -na Ignore project-local files for this run
--offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help
--version, -v Show version number

View File

@@ -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(

View File

@@ -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;
}

View File

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

View File

@@ -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" },

View 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);
});
}
}

View File

@@ -219,6 +219,7 @@ export {
type PackageSource,
type RetrySettings,
SettingsManager,
type SettingsManagerCreateOptions,
} from "./core/settings-manager.ts";
// Skills
export {
@@ -281,6 +282,7 @@ export {
type WriteToolOptions,
withFileMutationQueue,
} from "./core/tools/index.ts";
export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts";
// Main entry point
export { type MainOptions, main } from "./main.ts";
// Run modes for programmatic SDK usage

View File

@@ -40,6 +40,7 @@ import {
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
@@ -436,10 +437,11 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
async function showStartupSelector<T>(
settingsManager: SettingsManager,
): Promise<string | undefined> {
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
@@ -448,7 +450,7 @@ async function promptForMissingSessionCwd(
ui.setClearOnShrink(settingsManager.getClearOnShrink());
let settled = false;
const finish = (result: string | undefined) => {
const finish = (result: T | undefined) => {
if (settled) {
return;
}
@@ -458,9 +460,9 @@ async function promptForMissingSessionCwd(
};
const selector = new ExtensionSelectorComponent(
formatMissingSessionCwdPrompt(issue),
["Continue", "Cancel"],
(option) => finish(option === "Continue" ? issue.fallbackCwd : undefined),
title,
options.map((option) => option.label),
(option) => finish(options.find((entry) => entry.label === option)?.value),
() => finish(undefined),
{ tui: ui },
);
@@ -470,6 +472,69 @@ async function promptForMissingSessionCwd(
});
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
settingsManager: SettingsManager,
): Promise<string | undefined> {
return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [
{ label: "Continue", value: issue.fallbackCwd },
{ label: "Cancel", value: undefined },
]);
}
interface ProjectTrustPromptResult {
trusted: boolean;
remember: boolean;
}
async function promptForProjectTrust(
cwd: string,
settingsManager: SettingsManager,
): Promise<ProjectTrustPromptResult | undefined> {
return showStartupSelector(
settingsManager,
`Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`,
[
{ label: "Trust", value: { trusted: true, remember: true } },
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
{ label: "Do not trust", value: { trusted: false, remember: true } },
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
],
);
}
async function resolveProjectTrusted(options: {
cwd: string;
trustStore: ProjectTrustStore;
trustOverride?: boolean;
appMode: AppMode;
settingsManagerForPrompt: SettingsManager;
}): Promise<boolean> {
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
return true;
}
const decision = options.trustStore.get(options.cwd);
if (decision !== null) {
return decision;
}
if (options.appMode !== "interactive") {
return false;
}
const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt);
if (selected !== undefined) {
if (selected.remember) {
options.trustStore.set(options.cwd, selected.trusted);
}
return selected.trusted;
}
return false;
}
export interface MainOptions {
extensionFactories?: ExtensionFactory[];
}
@@ -581,6 +646,16 @@ export async function main(args: string[], options?: MainOptions) {
}
time("createSessionManager");
const trustStore = new ProjectTrustStore(agentDir);
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const projectTrustedForSession = await resolveProjectTrusted({
cwd: sessionManager.getCwd(),
trustStore,
trustOverride: parsed.projectTrustOverride,
appMode: trustPromptMode,
settingsManagerForPrompt: startupSettingsManager,
});
const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
@@ -592,10 +667,16 @@ export async function main(args: string[], options?: MainOptions) {
sessionManager,
sessionStartEvent,
}) => {
const projectTrusted =
cwd === sessionManager.getCwd()
? projectTrustedForSession
: (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true));
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const services = await createAgentSessionServices({
cwd,
agentDir,
authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags,
resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths,

View File

@@ -27,6 +27,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts";
export { ThinkingSelectorComponent } from "./thinking-selector.ts";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
export { TreeSelectorComponent } from "./tree-selector.ts";
export { TrustSelectorComponent } from "./trust-selector.ts";
export { UserMessageComponent } from "./user-message.ts";
export { UserMessageSelectorComponent } from "./user-message-selector.ts";
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts";

View File

@@ -0,0 +1,118 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import type { ProjectTrustDecision } from "../../../core/trust-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
interface TrustOption {
label: string;
trusted: boolean;
}
export interface TrustSelectorOptions {
cwd: string;
savedDecision: ProjectTrustDecision;
projectTrusted: boolean;
onSelect: (trusted: boolean) => void;
onCancel: () => void;
}
const TRUST_OPTIONS: TrustOption[] = [
{ label: "Trust", trusted: true },
{ label: "Do not trust", trusted: false },
];
function formatDecision(decision: ProjectTrustDecision): string {
if (decision === true) {
return "trusted";
}
if (decision === false) {
return "untrusted";
}
return "none";
}
export class TrustSelectorComponent extends Container {
private selectedIndex: number;
private readonly listContainer: Container;
private readonly savedDecision: ProjectTrustDecision;
private readonly onSelectCallback: (trusted: boolean) => void;
private readonly onCancelCallback: () => void;
constructor(options: TrustSelectorOptions) {
super();
this.savedDecision = options.savedDecision;
this.selectedIndex = Math.max(
0,
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision),
);
this.onSelectCallback = options.onSelect;
this.onCancelCallback = options.onCancel;
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0));
this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
);
this.addChild(new Spacer(1));
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
this.addChild(
new Text(
rawKeyHint("↑↓", "navigate") +
" " +
keyHint("tui.select.confirm", "save") +
" " +
keyHint("tui.select.cancel", "cancel"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < TRUST_OPTIONS.length; i++) {
const option = TRUST_OPTIONS[i];
if (!option) {
continue;
}
const isSelected = i === this.selectedIndex;
const isCurrent = option.trusted === this.savedDecision;
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0));
}
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selected = TRUST_OPTIONS[this.selectedIndex];
if (selected) {
this.onSelectCallback(selected.trusted);
}
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
}
}
}

View File

@@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@@ -120,6 +121,7 @@ import { SettingsSelectorComponent } from "./components/settings-selector.ts";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import {
@@ -2564,6 +2566,11 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/trust") {
this.showTrustSelector();
this.editor.setText("");
return;
}
if (text === "/login") {
this.showOAuthSelector("login");
this.editor.setText("");
@@ -3226,6 +3233,7 @@ export class InteractiveMode {
updateFooter: true,
populateHistory: true,
});
this.renderProjectTrustWarningIfNeeded();
// Show compaction info if session was compacted
const allEntries = this.sessionManager.getEntries();
@@ -3236,6 +3244,26 @@ export class InteractiveMode {
}
}
private renderProjectTrustWarningIfNeeded(): void {
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
return;
}
if (this.chatContainer.children.length > 0) {
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(
new Text(
theme.fg(
"warning",
"This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.",
),
1,
0,
),
);
}
async getUserInput(): Promise<string> {
const queuedInput = this.pendingUserInputs.shift();
if (queuedInput !== undefined) {
@@ -4135,6 +4163,31 @@ export class InteractiveMode {
}
}
private showTrustSelector(): void {
const cwd = this.sessionManager.getCwd();
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
const savedDecision = trustStore.get(cwd);
this.showSelector((done) => {
const selector = new TrustSelectorComponent({
cwd,
savedDecision,
projectTrusted: this.settingsManager.isProjectTrusted(),
onSelect: (trusted) => {
trustStore.set(cwd, trusted);
done();
this.showStatus(
`Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
);
},
onCancel: () => {
done();
this.ui.requestRender();
},
});
return { component: selector, focus: selector };
});
}
private showModelSelector(initialSearchInput?: string): void {
this.showSelector((done) => {
const selector = new ModelSelectorComponent(

View File

@@ -14,6 +14,7 @@ import {
} from "./config.ts";
import { DefaultPackageManager } from "./core/package-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import {
@@ -48,6 +49,7 @@ interface PackageCommandOptions {
updateTarget?: UpdateTarget;
local: boolean;
force: boolean;
projectTrustOverride?: boolean;
help: boolean;
invalidOption?: string;
invalidArgument?: string;
@@ -68,13 +70,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string)
function getPackageCommandUsage(command: PackageCommand): string {
switch (command) {
case "install":
return `${APP_NAME} install <source> [-l]`;
return `${APP_NAME} install <source> [-l] [--approve|--no-approve]`;
case "remove":
return `${APP_NAME} remove <source> [-l]`;
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--approve|--no-approve] [--force]`;
case "list":
return `${APP_NAME} list`;
return `${APP_NAME} list [--approve|--no-approve]`;
}
}
@@ -87,7 +89,9 @@ function printPackageCommandHelp(command: PackageCommand): void {
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-l, --local Install project-locally (.pi/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
Examples:
${APP_NAME} install npm:@foo/bar
@@ -107,7 +111,9 @@ Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-l, --local Remove from project settings (.pi/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
Examples:
${APP_NAME} remove npm:@foo/bar
@@ -125,6 +131,8 @@ Options:
--self Update pi only
--extensions Update installed packages only
--extension <source> Update one package only
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
--force Reinstall pi even if the current version is latest
Short forms:
@@ -139,6 +147,10 @@ Short forms:
${getPackageCommandUsage("list")}
List installed packages from user and project settings.
Options:
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
`);
return;
}
@@ -158,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
let local = false;
let force = false;
let projectTrustOverride: boolean | undefined;
let help = false;
let invalidOption: string | undefined;
let invalidArgument: string | undefined;
@@ -202,6 +215,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "--approve" || arg === "-a") {
projectTrustOverride = true;
continue;
}
if (arg === "--no-approve" || arg === "-na") {
projectTrustOverride = false;
continue;
}
if (arg === "--force") {
if (command === "update") {
force = true;
@@ -280,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
updateTarget,
local,
force,
projectTrustOverride,
help,
invalidOption,
invalidArgument,
@@ -389,6 +413,25 @@ function prepareWindowsNpmSelfUpdate(): void {
quarantineWindowsNativeDependencies(packageDir);
}
function parseProjectTrustOverride(args: readonly string[]): boolean | undefined {
let trustOverride: boolean | undefined;
for (const arg of args) {
if (arg === "--approve" || arg === "-a") {
trustOverride = true;
} else if (arg === "--no-approve" || arg === "-na") {
trustOverride = false;
}
}
return trustOverride;
}
function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean {
if (trustOverride !== undefined) {
return trustOverride;
}
return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true;
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") {
return false;
@@ -396,7 +439,8 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const projectTrusted = parseProjectTrustOverride(args) ?? true;
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
@@ -460,7 +504,14 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride);
if (!projectTrusted && writesProjectPackageConfig) {
console.error(chalk.red("Project is not trusted. Use --approve to modify local package config."));
process.exitCode = 1;
return true;
}
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;