From b4bff7f0d0eb074e56fbf2665a7bc6798567d701 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 23:37:16 +0200 Subject: [PATCH] fix(coding-agent): avoid project trust prompt for update (#5674) --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 8 +- packages/coding-agent/docs/security.md | 14 ++- packages/coding-agent/docs/settings.md | 6 +- packages/coding-agent/docs/usage.md | 8 +- .../coding-agent/src/core/project-trust.ts | 4 +- .../coding-agent/src/core/trust-manager.ts | 41 ++++--- packages/coding-agent/src/index.ts | 2 +- packages/coding-agent/src/main.ts | 14 ++- .../interactive/components/trust-selector.ts | 14 ++- .../src/modes/interactive/interactive-mode.ts | 6 +- .../coding-agent/src/package-manager-cli.ts | 15 ++- .../test/package-command-paths.test.ts | 66 ++++++++++- .../coding-agent/test/resource-loader.test.ts | 2 +- .../coding-agent/test/trust-manager.test.ts | 108 ++++++------------ 15 files changed, 185 insertions(+), 125 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e770ebdbf..1322e247a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,9 @@ ### Fixed +- Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)). - Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)). +>>>>>>> main - Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)). - Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)). - Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6e5c70595..41a7bbc28 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -291,15 +291,15 @@ 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 extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. @@ -527,7 +527,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. +`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. ### Modes diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 0c6d387a4..3a268a8af 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -6,14 +6,18 @@ Pi is a local coding agent. It runs with the permissions of the user account tha Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. -Pi considers a project to have trust inputs when it finds any of these from the current working directory: +Pi considers a project to have resources that require trust when it finds any of these from the current working directory: -- `.pi/` in the current directory -- `.agents/skills` in the current directory or an ancestor directory +- `.pi/settings.json` +- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes` +- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md` +- project `.agents/skills` in the current directory or an ancestor directory -When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. +A bare `.pi` directory does not count as a project resource that requires trust. -Trusting a project allows pi to load trust-gated project inputs, including: +When an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. + +Trusting a project allows pi to load project resources that require trust, including: - `.pi/settings.json` - `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 2cf843d11..fccfab063 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,13 +11,13 @@ Edit directly or use `/settings` for common options. ## Project Trust -On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to 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 an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bb8a4c253..4f8f5954f 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -112,15 +112,15 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. +`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. @@ -153,7 +153,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). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts index c8b572509..c0892439c 100644 --- a/packages/coding-agent/src/core/project-trust.ts +++ b/packages/coding-agent/src/core/project-trust.ts @@ -3,7 +3,7 @@ import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/typ import type { DefaultProjectTrust } from "./settings-manager.ts"; import { getProjectTrustOptions, - hasProjectTrustInputs, + hasTrustRequiringProjectResources, type ProjectTrustOption, type ProjectTrustStore, } from "./trust-manager.ts"; @@ -46,7 +46,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption if (options.trustOverride !== undefined) { return options.trustOverride; } - if (!hasProjectTrustInputs(options.cwd)) { + if (!hasTrustRequiringProjectResources(options.cwd)) { return true; } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index 69f616ae7..9c494b47a 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME } from "../config.ts"; @@ -25,6 +26,16 @@ export interface ProjectTrustOption { type TrustFile = Record; +const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ + "settings.json", + "extensions", + "skills", + "prompts", + "themes", + "SYSTEM.md", + "APPEND_SYSTEM.md", +] as const; + function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } @@ -45,18 +56,14 @@ function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreE } } -export function getProjectTrustPath(cwd: string): string { - return normalizeCwd(cwd); -} - export function getProjectTrustParentPath(cwd: string): string | undefined { - const trustPath = getProjectTrustPath(cwd); + const trustPath = normalizeCwd(cwd); const parentDir = dirname(trustPath); return parentDir === trustPath ? undefined : parentDir; } export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { - const trustPath = getProjectTrustPath(cwd); + const trustPath = normalizeCwd(cwd); const trustOptions: ProjectTrustOption[] = [ { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, ]; @@ -167,18 +174,26 @@ function withTrustFileLock(path: string, fn: () => T): T { } } -export function hasProjectConfigDir(cwd: string): boolean { - return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME)); -} - -export function hasProjectTrustInputs(cwd: string): boolean { +/** + * Returns true when cwd has project-local resources that must be gated by + * project trust: trust-requiring entries under cwd/.pi, or .agents/skills in + * cwd or one of its ancestors. Returns false when no such project resources + * exist. The user/global ~/.agents/skills directory is always treated as a + * trusted user resource and is ignored here, even when cwd is $HOME. + */ +export function hasTrustRequiringProjectResources(cwd: string): boolean { + const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); + const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); let currentDir = canonicalizePath(resolvePath(cwd)); - if (hasProjectConfigDir(currentDir)) { + + const configDir = join(currentDir, CONFIG_DIR_NAME); + if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { return true; } while (true) { - if (existsSync(join(currentDir, ".agents", "skills"))) { + const agentsSkillsDir = join(currentDir, ".agents", "skills"); + if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) { return true; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 958c7ebb1..3176a1675 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -289,7 +289,7 @@ export { withFileMutationQueue, } from "./core/tools/index.ts"; export { - hasProjectTrustInputs, + hasTrustRequiringProjectResources, type ProjectTrustDecision, ProjectTrustStore, type ProjectTrustStoreEntry, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0bc24685d..63d6ec1ef 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -41,7 +41,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 { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; @@ -572,7 +572,9 @@ export async function main(args: string[], options?: MainOptions) { const trustStore = new ProjectTrustStore(agentDir); const sessionCwd = sessionManager.getCwd(); const autoTrustOnReloadCwd = - parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; + parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd) + ? sessionCwd + : undefined; const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const projectTrustByCwd = new Map(); @@ -591,12 +593,14 @@ export async function main(args: string[], options?: MainOptions) { const isInitialRuntime = sessionStartEvent === undefined; const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = []; const cachedProjectTrust = projectTrustByCwd.get(cwd); - const hasTrustInputs = hasProjectTrustInputs(cwd); + const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd); const shouldResolveProjectTrust = - parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs; + parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources; const projectTrusted = shouldResolveProjectTrust ? false - : (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true)); + : (cachedProjectTrust ?? + parsed.projectTrustOverride ?? + (!hasTrustRequiringResources || trustStore.get(cwd) === true)); const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts index b7b1fe00c..92c232889 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,7 +1,6 @@ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; import { getProjectTrustOptions, - getProjectTrustPath, type ProjectTrustOption, type ProjectTrustStoreEntry, } from "../../../core/trust-manager.ts"; @@ -19,12 +18,12 @@ export interface TrustSelectorOptions { onCancel: () => void; } -function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string { +function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string { if (decision === null) { return "none"; } const label = decision.decision ? "trusted" : "untrusted"; - if (decision.path !== getProjectTrustPath(cwd)) { + if (trustPath !== undefined && decision.path !== trustPath) { return `${label} (inherited from ${decision.path})`; } return `${label} (${decision.path})`; @@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container { 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.cwd, options.savedDecision)}`), 1, 0), + new Text( + theme.fg( + "muted", + `Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`, + ), + 1, + 0, + ), ); this.addChild( new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d50611afa..fc25792e4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -86,7 +86,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 { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -3271,7 +3271,7 @@ export class InteractiveMode { } private renderProjectTrustWarningIfNeeded(): void { - if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) { + if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) { return; } @@ -4198,7 +4198,7 @@ export class InteractiveMode { if (this.autoTrustOnReloadCwd !== cwd) { return false; } - if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) { + if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) { return false; } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 94dbff85f..0dbe750c4 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -18,7 +18,7 @@ import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; -import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -452,13 +452,21 @@ async function createCommandSettingsManager(options: { cwd: string; agentDir: string; projectTrustOverride?: boolean; + useSavedProjectTrustOnly?: boolean; extensionFactories?: ExtensionFactory[]; }): Promise { const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); const projectTrustWarnings: string[] = []; + const trustStore = new ProjectTrustStore(options.agentDir); + if (options.useSavedProjectTrustOnly) { + const savedProjectTrusted = trustStore.get(options.cwd) === true; + settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted); + return { settingsManager, projectTrustWarnings }; + } + const appMode = getCommandAppMode(); const extensionsResult = - options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd) + options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd) ? await new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, @@ -472,7 +480,7 @@ async function createCommandSettingsManager(options: { const projectTrusted = await resolveProjectTrusted({ cwd: options.cwd, - trustStore: new ProjectTrustStore(options.agentDir), + trustStore, trustOverride: options.projectTrustOverride, defaultProjectTrust: settingsManager.getDefaultProjectTrust(), extensionsResult, @@ -576,6 +584,7 @@ export async function handlePackageCommand( cwd, agentDir, projectTrustOverride: options.projectTrustOverride, + useSavedProjectTrustOnly: options.command === "update", extensionFactories: runtimeOptions.extensionFactories, }); reportProjectTrustWarnings(projectTrustWarnings); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 25a55b359..1cff83f6f 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -202,6 +202,69 @@ describe("package commands", () => { } }); + it("does not prompt or ask extensions for project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + const fakeNpmPath = join(tempDir, "fake-project-npm.cjs"); + const recordPath = join(tempDir, "project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + let projectTrustCalled = false; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["update", "--extensions"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => { + projectTrustCalled = true; + return { trusted: "yes" }; + }); + }, + ], + }), + ).resolves.toBeUndefined(); + + expect(projectTrustCalled).toBe(false); + expect(existsSync(recordPath)).toBe(false); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses saved project trust during update", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs"); + const recordPath = join(tempDir, "trusted-project-update.json"); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, + ); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), + ); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["update", "--extensions"])).resolves.toBeUndefined(); + + expect(existsSync(recordPath)).toBe(true); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("lets trust.json override default project trust", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); @@ -223,6 +286,7 @@ describe("package commands", () => { it("blocks local package changes when project is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), "{}"); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 7258b1219..72cee79a7 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -376,7 +376,7 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); - it("should skip trust-gated project resources when project is not trusted", async () => { + it("should skip project resources that require trust when project is not trusted", async () => { const piDir = join(cwd, ".pi"); const extensionsDir = join(piDir, "extensions"); const skillDir = join(piDir, "skills", "project-skill"); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 2716da363..01500bac2 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -1,13 +1,8 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - getProjectTrustPath, - hasProjectConfigDir, - hasProjectTrustInputs, - ProjectTrustStore, -} from "../src/core/trust-manager.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -26,86 +21,47 @@ describe("ProjectTrustStore", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("stores decisions per cwd", () => { - const store = new ProjectTrustStore(agentDir); - - expect(store.get(cwd)).toBeNull(); - expect(store.getEntry(cwd)).toBeNull(); - store.set(cwd, true); - expect(store.get(cwd)).toBe(true); - expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true }); - store.set(cwd, false); - expect(store.get(cwd)).toBe(false); - expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false }); - store.set(cwd, null); - expect(store.get(cwd)).toBeNull(); - expect(store.getEntry(cwd)).toBeNull(); - }); - - it("inherits the closest saved decision from parent directories", () => { - const store = new ProjectTrustStore(agentDir); - const parentDir = join(tempDir, "trusted-parent"); - const childDir = join(parentDir, "project"); - const grandchildDir = join(childDir, "nested"); - mkdirSync(grandchildDir, { recursive: true }); - - store.set(parentDir, true); - expect(store.get(childDir)).toBe(true); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); - expect(store.get(grandchildDir)).toBe(true); - expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); - - store.set(childDir, false); - expect(store.get(grandchildDir)).toBe(false); - expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); - }); - - it("can clear a child override to inherit parent trust", () => { + it("stores decisions and inherits from parent directories", () => { const store = new ProjectTrustStore(agentDir); const parentDir = join(tempDir, "trusted-parent"); const childDir = join(parentDir, "project"); mkdirSync(childDir, { recursive: true }); + expect(store.get(childDir)).toBeNull(); store.set(parentDir, true); - store.set(childDir, false); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); - - store.setMany([ - { path: parentDir, decision: true }, - { path: childDir, decision: null }, - ]); expect(store.get(childDir)).toBe(true); - expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + store.set(childDir, false); + expect(store.get(childDir)).toBe(false); + store.set(childDir, null); + expect(store.get(childDir)).toBe(true); }); - 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); + it("detects trust-requiring project resources", () => { + const originalHome = process.env.HOME; + process.env.HOME = tempDir; + try { + mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true }); + mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(false); + expect(hasTrustRequiringProjectResources(cwd)).toBe(false); - 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"); - }); + writeFileSync(join(tempDir, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(true); + rmSync(join(tempDir, ".pi", "settings.json"), { force: true }); - it("detects project trust inputs", () => { - expect(hasProjectConfigDir(cwd)).toBe(false); - expect(hasProjectTrustInputs(cwd)).toBe(false); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync(join(cwd, ".pi", "settings.json"), "{}"); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); - mkdirSync(join(cwd, ".pi"), { recursive: true }); - expect(hasProjectConfigDir(cwd)).toBe(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(false); - rmSync(join(cwd, "AGENTS.md"), { force: true }); - - writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions"); - expect(hasProjectTrustInputs(cwd)).toBe(false); - rmSync(join(cwd, "CLAUDE.md"), { force: true }); - - mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); - expect(hasProjectTrustInputs(cwd)).toBe(true); + rmSync(join(cwd, ".pi"), { recursive: true, force: true }); + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } }); });