From e4132d75d80e2d683b2e6ed4bdfc762eb6d336a1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 3 Jun 2026 00:48:01 +0200 Subject: [PATCH] refactor(coding-agent): simplify project trust approvals --- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/package-manager.ts | 72 ++++----- .../coding-agent/src/core/resource-loader.ts | 7 +- packages/coding-agent/src/main.ts | 143 +++++------------- .../src/modes/interactive/interactive-mode.ts | 48 +++--- .../coding-agent/src/package-manager-cli.ts | 48 +++--- .../test/package-command-paths.test.ts | 20 +++ .../coding-agent/test/resource-loader.test.ts | 13 ++ 10 files changed, 146 insertions(+), 211 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1b89fc1ce..127b8836a 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 missing/null 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 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. ### Telemetry and update checks diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 4c27ee2fa..9f8bdd498 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 missing/null 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 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. ## All Settings diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 053592e9b..80eadd639 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 missing/null 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 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. ## Exporting and Sharing Sessions diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 7c0e96a17..13f9558bb 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2250,9 +2250,11 @@ export class DefaultPackageManager implements PackageManager { themes: join(projectBaseDir, "themes"), }; const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); - const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter( - (dir) => resolve(dir) !== resolve(userAgentsSkillsDir), - ); + const projectConfigTrusted = this.settingsManager.isProjectConfigTrusted(); + const includeProjectScopedResources = projectConfigTrusted || !existsSync(projectBaseDir); + const projectAgentsSkillDirs = includeProjectScopedResources + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + : []; const addResources = ( resourceType: ResourceType, @@ -2268,23 +2270,40 @@ export class DefaultPackageManager implements PackageManager { } }; - // Project extensions from .pi/ - addResources( - "extensions", - collectAutoExtensionEntries(projectDirs.extensions), - projectMetadata, - projectOverrides.extensions, - projectBaseDir, - ); + if (projectConfigTrusted) { + // Project extensions from .pi/ + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); - // Project skills from .pi/ - addResources( - "skills", - collectAutoSkillEntries(projectDirs.skills, "pi"), - projectMetadata, - projectOverrides.skills, - projectBaseDir, - ); + // Project skills from .pi/ + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills, "pi"), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } // Project skills from .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { @@ -2302,21 +2321,6 @@ export class DefaultPackageManager implements PackageManager { ); } - addResources( - "prompts", - collectAutoPromptEntries(projectDirs.prompts), - projectMetadata, - projectOverrides.prompts, - projectBaseDir, - ); - addResources( - "themes", - collectAutoThemeEntries(projectDirs.themes), - projectMetadata, - projectOverrides.themes, - projectBaseDir, - ); - // User extensions from ~/.pi/agent/ addResources( "extensions", diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index c980e9efd..609e8fedf 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -11,7 +11,7 @@ import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; import { createEventBus, type EventBus } from "./event-bus.ts"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; -import { DefaultPackageManager, type MissingSourceAction, type PathMetadata } from "./package-manager.ts"; +import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; @@ -145,7 +145,6 @@ export interface DefaultResourceLoaderOptions { agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; - onMissingPackage?: (source: string) => Promise; systemPromptOverride?: (base: string | undefined) => string | undefined; appendSystemPromptOverride?: (base: string[]) => string[]; } @@ -184,7 +183,6 @@ export class DefaultResourceLoader implements ResourceLoader { private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { agentsFiles: Array<{ path: string; content: string }>; }; - private onMissingPackage?: (source: string) => Promise; private systemPromptOverride?: (base: string | undefined) => string | undefined; private appendSystemPromptOverride?: (base: string[]) => string[]; @@ -232,7 +230,6 @@ export class DefaultResourceLoader implements ResourceLoader { this.promptsOverride = options.promptsOverride; this.themesOverride = options.themesOverride; this.agentsFilesOverride = options.agentsFilesOverride; - this.onMissingPackage = options.onMissingPackage; this.systemPromptOverride = options.systemPromptOverride; this.appendSystemPromptOverride = options.appendSystemPromptOverride; @@ -323,7 +320,7 @@ export class DefaultResourceLoader implements ResourceLoader { async reload(): Promise { await this.settingsManager.reload(); - const resolvedPaths = await this.packageManager.resolve(this.onMissingPackage); + const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e3275f239..dceb23b25 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -45,12 +45,8 @@ import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { - handleConfigCommand, - handlePackageCommand, - packageCommandForcesProjectConfigTrust, -} from "./package-manager-cli.ts"; -import { canonicalizePath, isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; +import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; /** @@ -441,18 +437,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -function getSessionTrustOverrideKey(cwd: string): string { - return canonicalizePath(resolvePath(cwd)); -} - -function isPackageCommandArg(arg: string | undefined): boolean { - return arg === "install" || arg === "remove" || arg === "uninstall" || arg === "update" || arg === "list"; -} - -function hasForceFlag(args: string[]): boolean { - return args.includes("--force") || args.includes("-f"); -} - async function showStartupSelector( settingsManager: SettingsManager, title: string, @@ -500,59 +484,45 @@ async function promptForMissingSessionCwd( ]); } -interface ProjectTrustPromptResult { - trusted: boolean; - remember: boolean; -} - -async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise { - const selected = await showStartupSelector( +async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise { + return showStartupSelector( settingsManager, `Trust project configuration?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`, [ - { label: "Yes (remember)", value: { trusted: true, remember: true } }, - { label: "Yes (this session)", value: { trusted: true, remember: false } }, - { label: "No (remember)", value: { trusted: false, remember: true } }, - { label: "No (this session)", value: { trusted: false, remember: false } }, + { label: "Trust", value: true }, + { label: "Don't trust", value: false }, ], ); - return selected ?? { trusted: false, remember: false }; -} - -interface ProjectTrustResolution { - trusted: boolean; - sessionOverride?: boolean; } async function resolveProjectConfigTrusted(options: { cwd: string; - agentDir: string; - sessionTrustOverride: boolean | undefined; + trustStore: ProjectTrustStore; + force: boolean; appMode: AppMode; settingsManagerForPrompt: SettingsManager; -}): Promise { - if (options.sessionTrustOverride !== undefined) { - return { trusted: options.sessionTrustOverride }; +}): Promise { + if (options.force) { + return true; } if (!hasProjectConfig(options.cwd)) { - return { trusted: false }; + return false; } - const trustStore = new ProjectTrustStore(options.agentDir); - const decision = trustStore.get(options.cwd); + const decision = options.trustStore.get(options.cwd); if (decision !== null) { - return { trusted: decision }; + return decision; } if (options.appMode !== "interactive") { - return { trusted: false }; + return false; } - const result = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); - if (result.remember) { - trustStore.set(options.cwd, result.trusted); - return { trusted: result.trusted }; + const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); + if (selected !== undefined) { + options.trustStore.set(options.cwd, selected); + return selected; } - return { trusted: result.trusted, sessionOverride: result.trusted }; + return false; } export interface MainOptions { @@ -571,27 +541,12 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } - if (isPackageCommandArg(args[0]) || args[0] === "config") { - const cwd = process.cwd(); - const agentDir = getAgentDir(); - const projectConfigExists = hasProjectConfig(cwd); - const forceProjectConfigTrusted = - args[0] === "config" ? hasForceFlag(args) : packageCommandForcesProjectConfigTrust(args); - const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); - const projectTrustResolution = await resolveProjectConfigTrusted({ - cwd, - agentDir, - sessionTrustOverride: forceProjectConfigTrusted ? true : undefined, - appMode: process.stdin.isTTY ? "interactive" : "print", - settingsManagerForPrompt: promptSettingsManager, - }); - const projectConfigTrusted = forceProjectConfigTrusted || projectTrustResolution.trusted; - if (await handlePackageCommand(args, { projectConfigTrusted, projectConfigExists })) { - return; - } - if (await handleConfigCommand(args, { projectConfigTrusted, projectConfigExists })) { - return; - } + if (await handlePackageCommand(args)) { + return; + } + + if (await handleConfigCommand(args)) { + return; } const parsed = parseArgs(args); @@ -643,21 +598,14 @@ export async function main(args: string[], options?: MainOptions) { const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const forceProjectConfigTrusted = parsed.force === true; - const sessionTrustOverrides = new Map(); - const getSessionTrustOverride = (targetCwd: string): boolean | undefined => { - return forceProjectConfigTrusted ? true : sessionTrustOverrides.get(getSessionTrustOverrideKey(targetCwd)); - }; - const startupTrustResolution = await resolveProjectConfigTrusted({ + const trustStore = new ProjectTrustStore(agentDir); + const startupProjectConfigTrusted = await resolveProjectConfigTrusted({ cwd, - agentDir, - sessionTrustOverride: getSessionTrustOverride(cwd), + trustStore, + force: forceProjectConfigTrusted, appMode: trustPromptMode, settingsManagerForPrompt: promptSettingsManager, }); - if (startupTrustResolution.sessionOverride !== undefined) { - sessionTrustOverrides.set(getSessionTrustOverrideKey(cwd), startupTrustResolution.sessionOverride); - } - const startupProjectConfigTrusted = startupTrustResolution.trusted; // 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. @@ -704,24 +652,15 @@ export async function main(args: string[], options?: MainOptions) { time("createSessionManager"); const initialRuntimeCwd = sessionManager.getCwd(); - let runtimeProjectConfigTrusted = startupProjectConfigTrusted; if (initialRuntimeCwd !== cwd) { - const runtimeTrustResolution = await resolveProjectConfigTrusted({ + await resolveProjectConfigTrusted({ cwd: initialRuntimeCwd, - agentDir, - sessionTrustOverride: getSessionTrustOverride(initialRuntimeCwd), + trustStore, + force: forceProjectConfigTrusted, appMode: trustPromptMode, settingsManagerForPrompt: promptSettingsManager, }); - if (runtimeTrustResolution.sessionOverride !== undefined) { - sessionTrustOverrides.set( - getSessionTrustOverrideKey(initialRuntimeCwd), - runtimeTrustResolution.sessionOverride, - ); - } - runtimeProjectConfigTrusted = runtimeTrustResolution.trusted; } - const trustStore = new ProjectTrustStore(agentDir); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); @@ -733,9 +672,7 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, sessionStartEvent, }) => { - const projectConfigTrusted = - getSessionTrustOverride(cwd) ?? - (cwd === initialRuntimeCwd ? runtimeProjectConfigTrusted : trustStore.get(cwd) === true); + const projectConfigTrusted = forceProjectConfigTrusted || (hasProjectConfig(cwd) && trustStore.get(cwd) === true); const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted }); const services = await createAgentSessionServices({ cwd, @@ -897,18 +834,6 @@ export async function main(args: string[], options?: MainOptions) { initialMessages: parsed.messages, verbose: parsed.verbose, forceProjectConfigTrust: forceProjectConfigTrusted, - setProjectConfigTrustOverride: (overrideCwd, trusted) => { - const key = getSessionTrustOverrideKey(overrideCwd); - if (trusted === undefined) { - sessionTrustOverrides.delete(key); - } else { - sessionTrustOverrides.set(key, trusted); - } - if (key === getSessionTrustOverrideKey(initialRuntimeCwd)) { - runtimeProjectConfigTrusted = - getSessionTrustOverride(initialRuntimeCwd) ?? trustStore.get(initialRuntimeCwd) === true; - } - }, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1228ca957..aece18308 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -258,8 +258,6 @@ export interface InteractiveModeOptions { verbose?: boolean; /** Force project config trust for this process */ forceProjectConfigTrust?: boolean; - /** Update project config trust override for this session and cwd */ - setProjectConfigTrustOverride?: (cwd: string, trusted: boolean | undefined) => void; } export class InteractiveMode { @@ -4949,25 +4947,22 @@ export class InteractiveMode { return "ask"; } - private async selectTrustDecision(): Promise<{ decision: ProjectTrustDecision; remember: boolean } | undefined> { + private async selectTrustDecision(): Promise { const trustStore = new ProjectTrustStore(getAgentDir()); 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.`, - ["Yes (remember)", "Yes (this session)", "No (remember)", "No (this session)"], + ["Trust", "Don't trust", "Reset"], ); - if (choice === "Yes (remember)") { - return { decision: true, remember: true }; + if (choice === "Trust") { + return true; } - if (choice === "Yes (this session)") { - return { decision: true, remember: false }; + if (choice === "Don't trust") { + return false; } - if (choice === "No (remember)") { - return { decision: false, remember: true }; - } - if (choice === "No (this session)") { - return { decision: false, remember: false }; + if (choice === "Reset") { + return null; } return undefined; } @@ -4983,40 +4978,31 @@ export class InteractiveMode { } const rawArg = text === "/trust" ? "" : text.slice("/trust".length).trim().toLowerCase(); - let selection: { decision: ProjectTrustDecision; remember: boolean } | undefined; + let decision: ProjectTrustDecision | undefined; if (!rawArg) { - selection = await this.selectTrustDecision(); + decision = await this.selectTrustDecision(); } else if (rawArg === "yes") { - selection = { decision: true, remember: true }; + decision = true; } else if (rawArg === "no") { - selection = { decision: false, remember: true }; + decision = false; } else if (rawArg === "reset") { - selection = { decision: null, remember: true }; + decision = null; } else { this.showError("Usage: /trust [yes|no|reset]"); return; } - if (selection === undefined) { + if (decision === undefined) { return; } const trustStore = new ProjectTrustStore(getAgentDir()); const cwd = this.sessionManager.getCwd(); - if (selection.remember) { - trustStore.set(cwd, selection.decision); - this.options.setProjectConfigTrustOverride?.(cwd, undefined); - } else { - this.options.setProjectConfigTrustOverride?.( - cwd, - selection.decision === null ? undefined : selection.decision, - ); - } - const projectConfigTrusted = this.options.forceProjectConfigTrust === true || selection.decision === true; + trustStore.set(cwd, decision); + const projectConfigTrusted = this.options.forceProjectConfigTrust === true || decision === true; this.settingsManager.setProjectConfigTrusted(projectConfigTrusted); await this.handleReloadCommand(); - const suffix = selection.remember ? "" : " (this session)"; - this.showStatus(`Project trust: ${this.formatTrustDecision(selection.decision)}${suffix}`); + this.showStatus(`Project trust: ${this.formatTrustDecision(decision)}`); } private async handleReloadCommand(): Promise { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 3ff90e5c0..438c191ca 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 } from "./core/trust-manager.ts"; +import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -56,11 +56,6 @@ interface PackageCommandOptions { conflictingOptions?: string; } -interface ProjectConfigCommandContext { - projectConfigTrusted?: boolean; - projectConfigExists?: boolean; -} - function reportSettingsErrors(settingsManager: SettingsManager, context: string): void { const errors = settingsManager.drainErrors(); for (const { scope, error } of errors) { @@ -295,14 +290,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined }; } -export function packageCommandForcesProjectConfigTrust(args: string[]): boolean { - const options = parsePackageCommand(args); - return ( - options?.force === true && - (options.command === "install" || options.command === "remove" || options.command === "list") - ); -} - function updateTargetIncludesSelf(target: UpdateTarget): boolean { return target.type === "all" || target.type === "self"; } @@ -404,16 +391,20 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } -export async function handleConfigCommand(args: string[], context: ProjectConfigCommandContext = {}): Promise { +export async function handleConfigCommand(args: string[]): Promise { if (args[0] !== "config") { return false; } const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir, { - projectConfigTrusted: context.projectConfigTrusted ?? true, - }); + const projectConfigExists = hasProjectConfig(cwd); + const projectConfigTrusted = + !projectConfigExists || + args.includes("--force") || + args.includes("-f") || + new ProjectTrustStore(agentDir).get(cwd) === true; + const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted }); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -428,10 +419,7 @@ export async function handleConfigCommand(args: string[], context: ProjectConfig process.exit(0); } -export async function handlePackageCommand( - args: string[], - context: ProjectConfigCommandContext = {}, -): Promise { +export async function handlePackageCommand(args: string[]): Promise { const options = parsePackageCommand(args); if (!options) { return false; @@ -479,20 +467,22 @@ export async function handlePackageCommand( } const cwd = process.cwd(); - const projectConfigTrusted = context.projectConfigTrusted ?? true; - const projectConfigExists = context.projectConfigExists ?? hasProjectConfig(cwd); + const agentDir = getAgentDir(); + const projectConfigExists = hasProjectConfig(cwd); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + const commandForcesProjectConfigTrust = + 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.")); process.exitCode = 1; return true; } - const agentDir = getAgentDir(); - const effectiveProjectConfigTrusted = projectConfigTrusted || (writesProjectPackageConfig && !projectConfigExists); - const settingsManager = SettingsManager.create(cwd, agentDir, { - projectConfigTrusted: effectiveProjectConfigTrusted, - }); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 7be08b778..91177f2f0 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; describe("package commands", () => { @@ -119,6 +120,25 @@ describe("package commands", () => { } }); + it("uses remembered project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("blocks local package changes when project config is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 4d3e15334..382baa27e 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -342,6 +342,19 @@ 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"); + mkdirSync(extensionsDir, { recursive: true }); + writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); + + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await loader.reload(); + + expect(loader.getExtensions().extensions).toHaveLength(0); + expect(loader.getExtensions().errors).toEqual([]); + }); + it("should discover APPEND_SYSTEM.md", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true });