fix(coding-agent): gate all project .pi access by trust

This commit is contained in:
Armin Ronacher
2026-06-03 12:07:44 +02:00
Unverified
parent 85c052dba3
commit faa794a8c9
16 changed files with 192 additions and 80 deletions
+2 -2
View File
@@ -290,7 +290,7 @@ See [docs/settings.md](docs/settings.md) for all options.
### Project Trust
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
### Telemetry and update checks
@@ -589,7 +589,7 @@ 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 |
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+1 -1
View File
@@ -11,7 +11,7 @@ Edit directly or use `/settings` for common options.
## Project Trust
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
## All Settings
+2 -2
View File
@@ -113,7 +113,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in tho
### Project Trust
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
## Exporting and Sharing Sessions
@@ -224,7 +224,7 @@ 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 |
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |
+1 -1
View File
@@ -269,7 +269,7 @@ ${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)
--force, -f Force loading project .pi config
--force, -f Force loading project .pi
--offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help
--version, -v Show version number
@@ -822,6 +822,7 @@ export class DefaultPackageManager implements PackageManager {
}
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
this.assertProjectPiTrustedForScope(scope);
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
const path = this.getNpmInstallPath(parsed, scope);
@@ -956,6 +957,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.assertProjectPiTrustedForScope(scope);
await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
@@ -984,6 +986,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.assertProjectPiTrustedForScope(scope);
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
@@ -1290,6 +1293,7 @@ export class DefaultPackageManager implements PackageManager {
}
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
this.assertProjectPiTrustedForScope(scope);
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, scope === "temporary");
return;
@@ -1666,6 +1670,12 @@ export class DefaultPackageManager implements PackageManager {
return { name, version };
}
private assertProjectPiTrustedForScope(scope: SourceScope): void {
if (scope === "project" && !this.settingsManager.isProjectConfigTrusted()) {
throw new Error("Project .pi 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) {
@@ -1886,6 +1896,7 @@ export class DefaultPackageManager implements PackageManager {
return this.getTemporaryDir("npm");
}
if (scope === "project") {
this.assertProjectPiTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.agentDir, "npm");
@@ -1926,6 +1937,7 @@ export class DefaultPackageManager implements PackageManager {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
this.assertProjectPiTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.agentDir, "npm", "node_modules", source.name);
@@ -1964,6 +1976,7 @@ export class DefaultPackageManager implements PackageManager {
return undefined;
}
if (scope === "project") {
this.assertProjectPiTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "git");
}
return join(this.agentDir, "git");
@@ -1989,6 +2002,7 @@ export class DefaultPackageManager implements PackageManager {
private getBaseDirForScope(scope: SourceScope): string {
if (scope === "project") {
this.assertProjectPiTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME);
}
if (scope === "user") {
@@ -2250,8 +2264,8 @@ export class DefaultPackageManager implements PackageManager {
themes: join(projectBaseDir, "themes"),
};
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectConfigTrusted = this.settingsManager.isProjectConfigTrusted();
const includeProjectScopedResources = projectConfigTrusted || !existsSync(projectBaseDir);
const projectPiTrusted = this.settingsManager.isProjectConfigTrusted();
const includeProjectScopedResources = projectPiTrusted || !existsSync(projectBaseDir);
const projectAgentsSkillDirs = includeProjectScopedResources
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
: [];
@@ -2270,7 +2284,7 @@ export class DefaultPackageManager implements PackageManager {
}
};
if (projectConfigTrusted) {
if (projectPiTrusted) {
// Project extensions from .pi/
addResources(
"extensions",
@@ -224,7 +224,7 @@ export class FileSettingsStorage implements SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
if (scope === "project" && !this.projectConfigTrusted) {
throw new Error("Project config is not trusted; refusing to access project settings");
throw new Error("Project .pi is not trusted; refusing to access project settings");
}
const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
@@ -272,7 +272,7 @@ export class InMemorySettingsStorage implements SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
if (scope === "project" && !this.projectConfigTrusted) {
throw new Error("Project config is not trusted; refusing to access project settings");
throw new Error("Project .pi is not trusted; refusing to access project settings");
}
const current = scope === "global" ? this.global : this.project;
@@ -535,7 +535,7 @@ export class SettingsManager {
private assertProjectConfigTrustedForWrite(): void {
if (!this.projectConfigTrusted) {
throw new Error("Project config is not trusted; refusing to write project settings");
throw new Error("Project .pi is not trusted; refusing to write project settings");
}
}
@@ -92,7 +92,7 @@ function withTrustFileLock<T>(path: string, fn: () => T): T {
}
}
export function hasProjectConfig(cwd: string): boolean {
export function hasProjectPiDirectory(cwd: string): boolean {
const resolvedCwd = resolvePath(cwd);
return existsSync(join(resolvedCwd, CONFIG_DIR_NAME));
}
+17 -18
View File
@@ -40,7 +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 { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasProjectPiDirectory, 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";
@@ -443,7 +443,7 @@ async function showStartupSelector<T>(
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
// Startup prompts run before resource loading. Themes from packages, --themes,
// and project config are unavailable here; built-ins and user theme files work.
// and project .pi resources are unavailable here; built-ins and user theme files work.
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
@@ -487,7 +487,7 @@ async function promptForMissingSessionCwd(
async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise<boolean | undefined> {
return showStartupSelector(
settingsManager,
`Trust project configuration?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
`Trust project .pi directory?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
[
{ label: "Trust", value: true },
{ label: "Don't trust", value: false },
@@ -495,7 +495,7 @@ async function promptForProjectTrust(cwd: string, settingsManager: SettingsManag
);
}
async function resolveProjectConfigTrusted(options: {
async function resolveProjectPiTrusted(options: {
cwd: string;
trustStore: ProjectTrustStore;
force: boolean;
@@ -505,7 +505,7 @@ async function resolveProjectConfigTrusted(options: {
if (options.force) {
return true;
}
if (!hasProjectConfig(options.cwd)) {
if (!hasProjectPiDirectory(options.cwd)) {
return false;
}
@@ -597,23 +597,22 @@ export async function main(args: string[], options?: MainOptions) {
const agentDir = getAgentDir();
const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const forceProjectConfigTrusted = parsed.force === true;
const forceProjectPiTrust = parsed.force === true;
const trustStore = new ProjectTrustStore(agentDir);
const startupProjectConfigTrusted = await resolveProjectConfigTrusted({
const startupProjectPiTrusted = await resolveProjectPiTrusted({
cwd,
trustStore,
force: forceProjectConfigTrusted,
force: forceProjectPiTrust,
appMode: trustPromptMode,
settingsManagerForPrompt: promptSettingsManager,
});
// Legacy extension migrations are intentional filesystem housekeeping and run
// regardless of trust. Trust gates loading/executing project config, not moving
// old Pi config directories to the current layout.
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd, {
projectPiTrusted: startupProjectPiTrusted,
});
time("runMigrations");
const startupSettingsManager = SettingsManager.create(cwd, agentDir, {
projectConfigTrusted: startupProjectConfigTrusted,
projectConfigTrusted: startupProjectPiTrusted,
});
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
@@ -653,10 +652,10 @@ export async function main(args: string[], options?: MainOptions) {
const initialRuntimeCwd = sessionManager.getCwd();
if (initialRuntimeCwd !== cwd) {
await resolveProjectConfigTrusted({
await resolveProjectPiTrusted({
cwd: initialRuntimeCwd,
trustStore,
force: forceProjectConfigTrusted,
force: forceProjectPiTrust,
appMode: trustPromptMode,
settingsManagerForPrompt: promptSettingsManager,
});
@@ -672,8 +671,8 @@ export async function main(args: string[], options?: MainOptions) {
sessionManager,
sessionStartEvent,
}) => {
const projectConfigTrusted = forceProjectConfigTrusted || (hasProjectConfig(cwd) && trustStore.get(cwd) === true);
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
const projectPiTrusted = forceProjectPiTrust || (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true);
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
const services = await createAgentSessionServices({
cwd,
agentDir,
@@ -833,7 +832,7 @@ export async function main(args: string[], options?: MainOptions) {
initialImages,
initialMessages: parsed.messages,
verbose: parsed.verbose,
forceProjectConfigTrust: forceProjectConfigTrusted,
forceProjectPiTrust,
});
if (startupBenchmark) {
await interactiveMode.init();
+14 -10
View File
@@ -384,22 +384,23 @@ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[]
/**
* Run extension system migrations (commands→prompts) and collect warnings about deprecated directories.
* This intentionally runs even for untrusted projects: it performs legacy Pi config
* housekeeping only and does not load or execute project extensions.
* Project .pi migrations only run after the project .pi directory is trusted.
*/
function migrateExtensionSystem(cwd: string): string[] {
function migrateExtensionSystem(cwd: string, options: { projectPiTrusted: boolean }): string[] {
const agentDir = getAgentDir();
const projectDir = join(cwd, CONFIG_DIR_NAME);
// Migrate commands/ to prompts/
migrateCommandsToPrompts(agentDir, "Global");
migrateCommandsToPrompts(projectDir, "Project");
if (options.projectPiTrusted) {
migrateCommandsToPrompts(projectDir, "Project");
}
// Check for deprecated directories
const warnings = [
...checkDeprecatedExtensionDirs(agentDir, "Global"),
...checkDeprecatedExtensionDirs(projectDir, "Project"),
];
const warnings = [...checkDeprecatedExtensionDirs(agentDir, "Global")];
if (options.projectPiTrusted) {
warnings.push(...checkDeprecatedExtensionDirs(projectDir, "Project"));
}
return warnings;
}
@@ -435,7 +436,10 @@ export async function showDeprecationWarnings(warnings: string[]): Promise<void>
*
* @returns Object with migration results and deprecation warnings
*/
export function runMigrations(cwd: string): {
export function runMigrations(
cwd: string,
options: { projectPiTrusted?: boolean } = {},
): {
migratedAuthProviders: string[];
deprecationWarnings: string[];
} {
@@ -444,6 +448,6 @@ export function runMigrations(cwd: string): {
migrateSessionsFromAgentRoot();
migrateToolsToBin();
migrateKeybindingsConfigFile();
const deprecationWarnings = migrateExtensionSystem(cwd);
const deprecationWarnings = migrateExtensionSystem(cwd, { projectPiTrusted: options.projectPiTrusted ?? true });
return { migratedAuthProviders, deprecationWarnings };
}
@@ -85,7 +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 { hasProjectConfig, type ProjectTrustDecision, ProjectTrustStore } from "../../core/trust-manager.ts";
import { hasProjectPiDirectory, type ProjectTrustDecision, 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";
@@ -256,8 +256,8 @@ export interface InteractiveModeOptions {
initialMessages?: string[];
/** Force verbose startup (overrides quietStartup setting) */
verbose?: boolean;
/** Force project config trust for this process */
forceProjectConfigTrust?: boolean;
/** Force project .pi trust for this process */
forceProjectPiTrust?: boolean;
}
export class InteractiveMode {
@@ -684,9 +684,9 @@ export class InteractiveMode {
this.headerContainer.addChild(this.builtInHeader);
}
if (!this.settingsManager.isProjectConfigTrusted() && hasProjectConfig(this.sessionManager.getCwd())) {
if (!this.settingsManager.isProjectConfigTrusted() && hasProjectPiDirectory(this.sessionManager.getCwd())) {
this.chatContainer.addChild(
new Text(theme.fg("warning", "This project is not trusted. Change with /trust"), 1, 0),
new Text(theme.fg("warning", "This project's .pi directory is not trusted. Change with /trust"), 1, 0),
);
}
@@ -4952,7 +4952,7 @@ export class InteractiveMode {
const cwd = this.sessionManager.getCwd();
const current = this.formatTrustDecision(trustStore.get(cwd));
const choice = await this.showExtensionSelector(
`Trust project configuration?\nCurrent setting: ${current}\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
`Trust project .pi directory?\nCurrent setting: ${current}\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
["Trust", "Don't trust", "Reset"],
);
if (choice === "Trust") {
@@ -4999,8 +4999,8 @@ export class InteractiveMode {
const trustStore = new ProjectTrustStore(getAgentDir());
const cwd = this.sessionManager.getCwd();
trustStore.set(cwd, decision);
const projectConfigTrusted = this.options.forceProjectConfigTrust === true || decision === true;
this.settingsManager.setProjectConfigTrusted(projectConfigTrusted);
const projectPiTrusted = this.options.forceProjectPiTrust === true || decision === true;
this.settingsManager.setProjectConfigTrusted(projectPiTrusted);
await this.handleReloadCommand();
this.showStatus(`Project trust: ${this.formatTrustDecision(decision)}`);
}
@@ -14,7 +14,7 @@ import {
} from "./config.ts";
import { DefaultPackageManager } from "./core/package-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasProjectPiDirectory, ProjectTrustStore } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import {
@@ -89,7 +89,7 @@ Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-f, --force Trust project config for this command
-f, --force Trust project .pi for this command
Examples:
${APP_NAME} install npm:@foo/bar
@@ -110,7 +110,7 @@ Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-f, --force Trust project config for this command
-f, --force Trust project .pi for this command
Examples:
${APP_NAME} remove npm:@foo/bar
@@ -144,7 +144,7 @@ Short forms:
List installed packages from user and project settings.
Options:
-f, --force Trust project config for this command
-f, --force Trust project .pi for this command
`);
return;
}
@@ -398,13 +398,13 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const projectConfigExists = hasProjectConfig(cwd);
const projectConfigTrusted =
!projectConfigExists ||
const projectPiExists = hasProjectPiDirectory(cwd);
const projectPiTrusted =
!projectPiExists ||
args.includes("--force") ||
args.includes("-f") ||
new ProjectTrustStore(agentDir).get(cwd) === true;
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
@@ -468,21 +468,21 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const projectConfigExists = hasProjectConfig(cwd);
const projectPiExists = hasProjectPiDirectory(cwd);
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
const commandForcesProjectConfigTrust =
const commandForcesProjectPiTrust =
options.force && (options.command === "install" || options.command === "remove" || options.command === "list");
const projectConfigTrusted =
commandForcesProjectConfigTrust ||
(projectConfigExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
(writesProjectPackageConfig && !projectConfigExists);
if (!projectConfigTrusted && projectConfigExists && writesProjectPackageConfig) {
console.error(chalk.red("Project config is not trusted. Use --force to modify local package config."));
const projectPiTrusted =
commandForcesProjectPiTrust ||
(projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
(writesProjectPackageConfig && !projectPiExists);
if (!projectPiTrusted && projectPiExists && writesProjectPackageConfig) {
console.error(chalk.red("Project .pi is not trusted. Use --force to modify local package config."));
process.exitCode = 1;
return true;
}
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;
@@ -0,0 +1,71 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts";
import { runMigrations } from "../src/migrations.ts";
describe("project .pi migration trust", () => {
let tempDir: string;
let agentDir: string;
let projectDir: string;
let previousAgentDir: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-migrations-trust-${Date.now()}-${Math.random().toString(36).slice(2)}`);
agentDir = join(tempDir, "agent");
projectDir = join(tempDir, "project");
mkdirSync(agentDir, { recursive: true });
mkdirSync(projectDir, { recursive: true });
previousAgentDir = process.env[ENV_AGENT_DIR];
process.env[ENV_AGENT_DIR] = agentDir;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
if (previousAgentDir === undefined) {
delete process.env[ENV_AGENT_DIR];
} else {
process.env[ENV_AGENT_DIR] = previousAgentDir;
}
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});
function createLegacyProjectPi(): void {
const projectPiDir = join(projectDir, ".pi");
mkdirSync(join(projectPiDir, "commands"), { recursive: true });
mkdirSync(join(projectPiDir, "hooks"), { recursive: true });
mkdirSync(join(projectPiDir, "tools"), { recursive: true });
writeFileSync(join(projectPiDir, "commands", "project.md"), "project prompt");
writeFileSync(join(projectPiDir, "tools", "custom-tool"), "custom tool");
}
it("does not migrate or warn for project .pi paths when untrusted", () => {
mkdirSync(join(agentDir, "commands"), { recursive: true });
writeFileSync(join(agentDir, "commands", "global.md"), "global prompt");
createLegacyProjectPi();
const result = runMigrations(projectDir, { projectPiTrusted: false });
expect(existsSync(join(agentDir, "prompts", "global.md"))).toBe(true);
expect(existsSync(join(projectDir, ".pi", "commands", "project.md"))).toBe(true);
expect(existsSync(join(projectDir, ".pi", "prompts"))).toBe(false);
expect(result.deprecationWarnings.some((warning) => warning.includes("Project"))).toBe(false);
});
it("migrates and warns for project .pi migration paths when trusted", () => {
createLegacyProjectPi();
const result = runMigrations(projectDir, { projectPiTrusted: true });
expect(existsSync(join(projectDir, ".pi", "commands"))).toBe(false);
expect(existsSync(join(projectDir, ".pi", "prompts", "project.md"))).toBe(true);
expect(result.deprecationWarnings).toContain(
"Project hooks/ directory found. Hooks have been renamed to extensions.",
);
expect(result.deprecationWarnings).toContain(
"Project tools/ directory contains custom tools. Custom tools have been merged into extensions.",
);
});
});
@@ -139,7 +139,7 @@ describe("package commands", () => {
}
});
it("blocks local package changes when project config is untrusted", async () => {
it("blocks local package changes when project .pi is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -147,14 +147,14 @@ describe("package commands", () => {
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain("Project config is not trusted. Use --force to modify local package config.");
expect(stderr).toContain("Project .pi is not trusted. Use --force to modify local package config.");
expect(process.exitCode).toBe(1);
} finally {
errorSpy.mockRestore();
}
});
it("allows local package install to initialize fresh project config", async () => {
it("allows local package install to initialize fresh project .pi", async () => {
await main(["install", "-l", packageDir]);
const settingsPath = join(projectDir, ".pi", "settings.json");
@@ -329,7 +329,7 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
});
it("should skip .pi SYSTEM.md when project config is not trusted", async () => {
it("should skip .pi SYSTEM.md when project .pi is not trusted", async () => {
const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true });
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
@@ -342,10 +342,31 @@ Content`,
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
});
it("should skip .pi extensions when project config is not trusted", async () => {
const extensionsDir = join(cwd, ".pi", "extensions");
it("should skip .pi resources when project .pi 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(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, { projectConfigTrusted: false });
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
@@ -353,6 +374,9 @@ Content`,
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 () => {
@@ -258,8 +258,8 @@ describe("SettingsManager", () => {
});
});
describe("project config trust", () => {
it("should skip project settings when project config is not trusted", () => {
describe("project .pi trust", () => {
it("should skip project settings when project .pi is not trusted", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
@@ -269,13 +269,13 @@ describe("SettingsManager", () => {
expect(manager.getProjectSettings()).toEqual({});
});
it("should fail project settings writes when project config is not trusted", async () => {
it("should fail project settings writes when project .pi is not trusted", async () => {
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] }));
const manager = SettingsManager.create(projectDir, agentDir, { projectConfigTrusted: false });
expect(() => manager.setProjectPackages(["npm:new"])).toThrow(
"Project config is not trusted; refusing to write project settings",
"Project .pi is not trusted; refusing to write project settings",
);
await manager.flush();
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import lockfile from "proper-lockfile";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasProjectConfig, ProjectTrustStore } from "../src/core/trust-manager.ts";
import { hasProjectPiDirectory, ProjectTrustStore } from "../src/core/trust-manager.ts";
describe("ProjectTrustStore", () => {
let tempDir: string;
@@ -67,10 +67,10 @@ describe("ProjectTrustStore", () => {
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
});
it("detects .pi project config directories", () => {
expect(hasProjectConfig(cwd)).toBe(false);
it("detects project .pi directories", () => {
expect(hasProjectPiDirectory(cwd)).toBe(false);
mkdirSync(join(cwd, ".pi"), { recursive: true });
expect(hasProjectConfig(cwd)).toBe(true);
expect(hasProjectPiDirectory(cwd)).toBe(true);
});
});