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

@@ -4,6 +4,7 @@
### Added
- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)).
- Added the latest prompt cache hit rate to the interactive footer.
### Fixed

View File

@@ -186,6 +186,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/name <name>` | Set session display name |
| `/session` | Show session info (file, ID, messages, tokens, cost) |
| `/tree` | Jump to any point in the session and continue from there |
| `/trust` | Save project trust decision for future sessions (restart required) |
| `/fork` | Create a new session from a previous user message |
| `/clone` | Duplicate the current active branch into a new session |
| `/compact [prompt]` | Manually compact context, optional custom instructions |
@@ -288,6 +289,16 @@ Use `/settings` to modify common options, or edit JSON files directly:
See [docs/settings.md](docs/settings.md) for all options.
### Project Trust
On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted.
`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`.
Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
### Telemetry and update checks
Pi has two separate startup features:
@@ -303,10 +314,10 @@ Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations desc
Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from:
- `~/.pi/agent/AGENTS.md` (global)
- Parent directories (walking up from cwd)
- Current directory
- Parent directories (walking up from cwd, only when the project is trusted)
- Current directory (only when the project is trusted)
Use for project instructions, conventions, common commands. All matching files are concatenated.
Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated.
Disable context file loading with `--no-context-files` (or `-nc`).
@@ -512,6 +523,8 @@ pi list # List installed packages
pi config # Enable/disable package resources
```
Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command.
### Modes
| Flag | Description |
@@ -585,6 +598,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup |
| `-a`, `--approve` | Trust project-local files for this run |
| `-na`, `--no-approve` | Ignore project-local files for this run |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |

View File

@@ -109,7 +109,7 @@ pi -e ./my-extension.ts
> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.
Extensions are auto-discovered from:
Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted.
| Location | Scope |
|----------|-------|

View File

@@ -38,7 +38,9 @@ pi update --extension npm:@foo/bar
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup.
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted.
Project package commands read project settings only when the project is trusted. Use `--approve` to trust project-local files for one command, or `--no-approve` to ignore them for one command.
To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:

View File

@@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam
Pi loads prompt templates from:
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Project: `.pi/prompts/*.md` (only after the project is trusted)
- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)

View File

@@ -9,6 +9,16 @@ Pi uses JSON settings files with project settings overriding global settings.
Edit directly or use `/settings` for common options.
## Project Trust
On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted.
`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`.
Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
## All Settings
### Model & Thinking

View File

@@ -26,7 +26,7 @@ Pi loads skills from:
- Global:
- `~/.pi/agent/skills/`
- `~/.agents/skills/`
- Project:
- Project (only after the project is trusted):
- `.pi/skills/`
- `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)
- Packages: `skills/` directories or `pi.skills` entries in `package.json`

View File

@@ -20,7 +20,7 @@ Pi loads themes from:
- Built-in: `dark`, `light`
- Global: `~/.pi/agent/themes/*.json`
- Project: `.pi/themes/*.json`
- Project: `.pi/themes/*.json` (only after the project is trusted)
- Packages: `themes/` directories or `pi.themes` entries in `package.json`
- Settings: `themes` array with files or directories
- CLI: `--theme <path>` (repeatable)

View File

@@ -96,8 +96,8 @@ See [Sessions](sessions.md) and [Compaction](compaction.md) for details.
Pi loads `AGENTS.md` or `CLAUDE.md` at startup from:
- `~/.pi/agent/AGENTS.md` for global instructions
- parent directories, walking up from the current working directory
- the current directory
- parent directories, walking up from the current working directory when the project is trusted
- the current directory when the project is trusted
Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`.
@@ -110,6 +110,16 @@ Replace the default system prompt with:
Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location.
### Project Trust
On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted.
`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`.
Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect.
## Exporting and Sharing Sessions
Use `/export [file]` to write a session to HTML.
@@ -138,7 +148,7 @@ pi list # List installed packages
pi config # Enable/disable package resources
```
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command.
See [Pi Packages](packages.md) for package sources and security notes.
@@ -219,6 +229,8 @@ pi --no-extensions -e ./my-extension.ts
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup |
| `-a`, `--approve` | Trust project-local files for this run |
| `-na`, `--no-approve` | Ignore project-local files for this run |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |

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;

View File

@@ -293,6 +293,28 @@ describe("parseArgs", () => {
});
});
describe("project approval flags", () => {
test("parses --approve", () => {
const result = parseArgs(["--approve"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses -a shorthand", () => {
const result = parseArgs(["-a"]);
expect(result.projectTrustOverride).toBe(true);
});
test("parses --no-approve", () => {
const result = parseArgs(["--no-approve"]);
expect(result.projectTrustOverride).toBe(false);
});
test("parses -na shorthand", () => {
const result = parseArgs(["-na"]);
expect(result.projectTrustOverride).toBe(false);
});
});
describe("--verbose flag", () => {
test("parses --verbose flag", () => {
const result = parseArgs(["--verbose"]);

View File

@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
import { ProjectTrustStore } from "../src/core/trust-manager.ts";
import { main } from "../src/main.ts";
describe("package commands", () => {
@@ -85,6 +86,103 @@ describe("package commands", () => {
expect(removedSettings.packages ?? []).toHaveLength(0);
});
it("skips untrusted project package settings", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
} finally {
logSpy.mockRestore();
}
});
it("uses remembered project trust for list", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("overrides remembered trust for list with --no-approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
new ProjectTrustStore(agentDir).set(projectDir, true);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--no-approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("No packages installed.");
expect(stdout).not.toContain("Project packages:");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("approves project trust for list with --approve", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(main(["list", "--approve"])).resolves.toBeUndefined();
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stdout).toContain("Project packages:");
expect(stdout).toContain("npm:@project/pkg");
expect(stdout).not.toContain("No packages installed.");
expect(process.exitCode).toBeUndefined();
} finally {
logSpy.mockRestore();
}
});
it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config.");
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
}
});
it("allows local package install to initialize fresh project settings", async () => {
await main(["install", "-l", packageDir]);
const settingsPath = join(projectDir, ".pi", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] };
expect(settings.packages?.length).toBe(1);
const stored = settings.packages?.[0] ?? "";
expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir));
expect(process.exitCode).toBeUndefined();
});
it("shows install subcommand help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -111,7 +209,7 @@ describe("package commands", () => {
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain('Unknown option --unknown for "install".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [--approve|--no-approve]".');
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();

View File

@@ -329,6 +329,52 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should skip project resources when project is not trusted", async () => {
const piDir = join(cwd, ".pi");
const extensionsDir = join(piDir, "extensions");
const skillDir = join(piDir, "skills", "project-skill");
const promptsDir = join(piDir, "prompts");
const themesDir = join(piDir, "themes");
mkdirSync(extensionsDir, { recursive: true });
mkdirSync(skillDir, { recursive: true });
mkdirSync(promptsDir, { recursive: true });
mkdirSync(themesDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt.");
writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions");
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`);
writeFileSync(
join(skillDir, "SKILL.md"),
`---
name: project-skill
description: Project skill
---
Project skill content`,
);
writeFileSync(join(promptsDir, "project.md"), "Project prompt");
const themeData = JSON.parse(
readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"),
) as { name: string };
themeData.name = "project-theme";
writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2));
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
true,
);
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false);
expect(loader.getExtensions().extensions).toHaveLength(0);
expect(loader.getExtensions().errors).toEqual([]);
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);
expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false);
expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false);
});
it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });

View File

@@ -214,6 +214,44 @@ describe("SettingsManager", () => {
});
});
describe("project trust", () => {
it("should skip project settings when project is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(manager.isProjectTrusted()).toBe(false);
expect(manager.getTheme()).toBe("global");
expect(manager.getProjectSettings()).toEqual({});
});
it("should reload project settings after trust changes to true", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
manager.setProjectTrusted(true);
expect(manager.isProjectTrusted()).toBe(true);
expect(manager.getTheme()).toBe("project");
});
it("should fail project settings writes when project is not trusted", async () => {
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] }));
const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false });
expect(() => manager.setProjectPackages(["npm:new"])).toThrow(
"Project is not trusted; refusing to write project settings",
);
await manager.flush();
expect(manager.getProjectSettings()).toEqual({});
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
});
});
describe("project settings directory creation", () => {
it("should not create .pi folder when only reading project settings", () => {
// Create agent dir with global settings, but NO .pi folder in project

View File

@@ -80,8 +80,8 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
}
describe("stdout cleanliness in non-interactive modes", () => {
it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help"]);
it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help", "--approve"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
@@ -90,13 +90,23 @@ describe("stdout cleanliness in non-interactive modes", () => {
expect(result.stderr).toContain("Usage:");
});
it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => {
it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["-p", "--help", "--approve"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("changed 1 package in 471ms");
expect(result.stderr).toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
it("ignores untrusted project package installs for help", async () => {
const result = await runCli(["-p", "--help"]);
expect(result.code).toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("changed 1 package in 471ms");
expect(result.stderr).toContain("found 0 vulnerabilities");
expect(result.stderr).not.toContain("changed 1 package in 471ms");
expect(result.stderr).not.toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:");
});
});

View File

@@ -0,0 +1,60 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
beforeEach(() => {
tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(cwd, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("stores decisions per cwd", () => {
const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull();
store.set(cwd, true);
expect(store.get(cwd)).toBe(true);
store.set(cwd, false);
expect(store.get(cwd)).toBe(false);
store.set(cwd, null);
expect(store.get(cwd)).toBeNull();
});
it("fails loudly without overwriting malformed trust stores", () => {
const trustPath = join(agentDir, "trust.json");
writeFileSync(trustPath, "{not json", "utf-8");
const store = new ProjectTrustStore(agentDir);
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
expect(() => store.set(cwd, true)).toThrow(/Failed to read trust store/);
expect(readFileSync(trustPath, "utf-8")).toBe("{not json");
});
it("detects project trust inputs", () => {
expect(hasProjectTrustInputs(cwd)).toBe(false);
mkdirSync(join(cwd, ".pi"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true);
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
expect(hasProjectTrustInputs(cwd)).toBe(true);
rmSync(join(cwd, "AGENTS.md"), { force: true });
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("TrustSelectorComponent", () => {
beforeAll(() => {
initTheme("dark");
});
beforeEach(() => {
setKeybindings(new KeybindingsManager());
});
it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: true,
projectTrusted: true,
onSelect: () => {},
onCancel: () => {},
});
const output = stripAnsi(selector.render(120).join("\n"));
expect(output).toContain("Saved decision: trusted");
expect(output).toContain("Current session: trusted");
expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓");
});
it("selects a trust decision", () => {
const onSelect = vi.fn();
const selector = new TrustSelectorComponent({
cwd: "/project",
savedDecision: null,
projectTrusted: false,
onSelect,
onCancel: () => {},
});
selector.handleInput("\n");
expect(onSelect).toHaveBeenCalledWith(true);
});
});