From faa794a8c99becc0b9ba997c327ec7e20d13e80b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 3 Jun 2026 12:07:44 +0200 Subject: [PATCH] fix(coding-agent): gate all project .pi access by trust --- packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/usage.md | 4 +- packages/coding-agent/src/cli/args.ts | 2 +- .../coding-agent/src/core/package-manager.ts | 20 +++++- .../coding-agent/src/core/settings-manager.ts | 6 +- .../coding-agent/src/core/trust-manager.ts | 2 +- packages/coding-agent/src/main.ts | 35 +++++---- packages/coding-agent/src/migrations.ts | 24 ++++--- .../src/modes/interactive/interactive-mode.ts | 16 ++--- .../coding-agent/src/package-manager-cli.ts | 34 ++++----- .../test/migrations-trust.test.ts | 71 +++++++++++++++++++ .../test/package-command-paths.test.ts | 6 +- .../coding-agent/test/resource-loader.test.ts | 30 +++++++- .../test/settings-manager.test.ts | 8 +-- .../coding-agent/test/trust-manager.test.ts | 8 +-- 16 files changed, 192 insertions(+), 80 deletions(-) create mode 100644 packages/coding-agent/test/migrations-trust.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 127b8836a..4a07483fd 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | 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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 9f8bdd498..5618d2c46 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 80eadd639..2b235eea3 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | 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 | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index e6b2db709..d1fef4908 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -269,7 +269,7 @@ ${chalk.bold("Options:")} --export 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 diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 13f9558bb..4a1e04785 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 { 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 { 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 { + 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", diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ce88d9d96..7315a41f6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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"); } } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index fb6b9ffab..5f54e00cf 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -92,7 +92,7 @@ function withTrustFileLock(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)); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index dceb23b25..0be26b00b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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( options: Array<{ label: string; value: T }>, ): Promise { // 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 { 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(); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 98cc59dde..9f5f457a5 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -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 * * @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 }; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index aece18308..65918f3d7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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)}`); } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 438c191ca..04fd2d53e 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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 [-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 { 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 { 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; diff --git a/packages/coding-agent/test/migrations-trust.test.ts b/packages/coding-agent/test/migrations-trust.test.ts new file mode 100644 index 000000000..a3b81dc13 --- /dev/null +++ b/packages/coding-agent/test/migrations-trust.test.ts @@ -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.", + ); + }); +}); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 91177f2f0..723512738 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -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"); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 382baa27e..4a0894e3a 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 829665961..cb773ea76 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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(); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index c2c7a8813..06c82edbf 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -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); }); });