diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 202b6f3e9..0c9ee09f4 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 inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. The closest saved decision on the current or parent path applies, so trusting `~/Development` also trusts `~/Development/pi` unless a narrower decision overrides it. Before the trust decision, pi loads only user/global extensions and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, project settings, and project instructions 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 a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. `pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +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. ### Telemetry and update checks diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index 1e70a2d58..4a6269659 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -12,7 +12,7 @@ Pi considers a project to have trust inputs when it finds any of these from the - `AGENTS.md` or `CLAUDE.md` in the current directory or an ancestor directory - `.agents/skills` in the current directory or an ancestor directory -When an interactive session starts in a project with trust inputs and no saved decision, pi asks whether to trust the project. Saved decisions are stored per canonical working directory in `~/.pi/agent/trust.json`. +When an interactive session starts in a project with trust inputs and no saved decision for the current directory or a parent directory, pi asks whether to trust the project. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`; the closest decision on the current or parent path applies, so trusting `~/Development` also trusts `~/Development/pi` unless a narrower decision overrides it. Trusting a project allows pi to load project-local inputs, including: @@ -24,7 +24,9 @@ Trusting a project allows pi to load project-local inputs, including: Declining trust skips those project-local inputs. Before trust is resolved, pi only loads user/global extensions and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +The global `projectTrust` setting answers whether unknown projects should be trusted to load extensions and agent files: `"ask"` asks when unknown, `"always"` always trusts (override with `-na`), and `"never"` never trusts (override with `-a`). Saved decisions, CLI approval flags, and extension `project_trust` handlers take precedence. ## No Built-in Sandbox diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index df2d0bd61..757b68ad1 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,13 +11,15 @@ Edit directly or use `/settings` for common options. ## Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. The closest saved decision on the current or parent path applies, so trusting `~/Development` also trusts `~/Development/pi` unless a narrower decision overrides it. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. + +Set global `projectTrust` to `"ask"` (ask when unknown, default), `"always"` (always trust; override with `-na`), or `"never"` (never trust; override with `-a`). Saved decisions in `trust.json`, `--approve`/`--no-approve`, and extension `project_trust` handlers take precedence. `pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +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. ## All Settings @@ -44,6 +46,12 @@ Use `/trust` in interactive mode to save a project trust decision for future ses } ``` +### Security + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `projectTrust` | string | `"ask"` | Should unknown projects be trusted to load extensions and agent files? `"ask"` asks when unknown, `"always"` always trusts (override with `-na`), and `"never"` never trusts (override with `-a`). This is read from global settings only. | + ### UI & Display | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ad9b80e14..d9023ca35 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -112,13 +112,13 @@ 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 inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. The closest saved decision on the current or parent path applies, so trusting `~/Development` also trusts `~/Development/pi` unless a narrower decision overrides it. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. `pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +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. ## Exporting and Sharing Sessions diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2ef32d6d6..f8b749548 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -57,6 +57,8 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export type ProjectTrustSetting = "ask" | "always" | "never"; + export type TransportSetting = Transport; /** @@ -89,6 +91,7 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; + projectTrust?: ProjectTrustSetting; // default: "ask" - global default for project trust when no saved decision exists shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) @@ -853,6 +856,17 @@ export class SettingsManager { this.save(); } + getProjectTrustSetting(): ProjectTrustSetting { + const value = this.globalSettings.projectTrust; + return value === "always" || value === "never" ? value : "ask"; + } + + setProjectTrustSetting(setting: ProjectTrustSetting): void { + this.globalSettings.projectTrust = setting; + this.markModified("projectTrust"); + this.save(); + } + getShellCommandPrefix(): string | undefined { return this.settings.shellCommandPrefix; } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index c86c85185..cbfa6de24 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -6,6 +6,23 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts"; export type ProjectTrustDecision = boolean | null; +export interface ProjectTrustStoreEntry { + path: string; + decision: boolean; +} + +export interface ProjectTrustUpdate { + cwd: string; + decision: ProjectTrustDecision; +} + +export interface ProjectTrustOption { + label: string; + trusted: boolean; + updates: ProjectTrustUpdate[]; + savedPath?: string; +} + type TrustFile = Record; const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; @@ -14,6 +31,64 @@ function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } +function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { + let currentDir = normalizeCwd(cwd); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) { + return { path: currentDir, decision: value }; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getProjectTrustPath(cwd: string): string { + return normalizeCwd(cwd); +} + +export function getProjectTrustParentPath(cwd: string): string | undefined { + const normalizedCwd = getProjectTrustPath(cwd); + const parentDir = dirname(normalizedCwd); + return parentDir === normalizedCwd ? undefined : parentDir; +} + +export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { + const trustPath = getProjectTrustPath(cwd); + const trustOptions: ProjectTrustOption[] = [ + { label: "Trust", trusted: true, updates: [{ cwd, decision: true }], savedPath: trustPath }, + ]; + const parentPath = getProjectTrustParentPath(cwd); + if (parentPath !== undefined) { + trustOptions.push({ + label: `Trust parent folder (${parentPath})`, + trusted: true, + updates: [ + { cwd: parentPath, decision: true }, + { cwd, decision: null }, + ], + savedPath: parentPath, + }); + } + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] }); + } + trustOptions.push({ + label: "Do not trust", + trusted: false, + updates: [{ cwd, decision: false }], + savedPath: trustPath, + }); + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] }); + } + return trustOptions; +} + function readTrustFile(path: string): TrustFile { if (!existsSync(path)) { return {}; @@ -130,21 +205,30 @@ export class ProjectTrustStore { } get(cwd: string): ProjectTrustDecision { + return this.getEntry(cwd)?.decision ?? null; + } + + getEntry(cwd: string): ProjectTrustStoreEntry | null { return withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const value = data[normalizeCwd(cwd)]; - return value === true || value === false ? value : null; + return findNearestTrustEntry(data, cwd); }); } set(cwd: string, decision: ProjectTrustDecision): void { + this.setMany([{ cwd, decision }]); + } + + setMany(decisions: ProjectTrustUpdate[]): void { withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const key = normalizeCwd(cwd); - if (decision === null) { - delete data[key]; - } else { - data[key] = decision; + for (const { cwd, decision } of decisions) { + const key = normalizeCwd(cwd); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } } writeTrustFile(this.trustPath, data); }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 398f5430d..ad5845e90 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -222,6 +222,7 @@ export { type CompactionSettings, type ImageSettings, type PackageSource, + type ProjectTrustSetting, type RetrySettings, SettingsManager, type SettingsManagerCreateOptions, @@ -287,7 +288,13 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; -export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts"; +export { + hasProjectTrustInputs, + type ProjectTrustDecision, + ProjectTrustStore, + type ProjectTrustStoreEntry, + type ProjectTrustUpdate, +} from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 335d814aa..4671b5b82 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -41,7 +41,12 @@ 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 { + getProjectTrustOptions, + hasProjectTrustInputs, + type ProjectTrustOption, + ProjectTrustStore, +} from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts"; @@ -530,38 +535,40 @@ async function promptForMissingSessionCwd( ]); } -interface ProjectTrustPromptResult { - trusted: boolean; - remember: boolean; +function getProjectTrustPromptOptions(cwd: string): Array<{ label: string; value: ProjectTrustOption }> { + return getProjectTrustOptions(cwd, { includeSessionOnly: true }).map((option) => ({ + label: option.label, + value: option, + })); } -const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [ - { label: "Trust", value: { trusted: true, remember: true } }, - { label: "Trust (this session only)", value: { trusted: true, remember: false } }, - { label: "Do not trust", value: { trusted: false, remember: true } }, - { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, -]; +function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void { + if (result.updates.length > 0) { + trustStore.setMany(result.updates); + } +} function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`; + return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.\n\nChange this default in /settings with Trust projects.`; } async function promptForProjectTrust( cwd: string, settingsManager: SettingsManager, -): Promise { - return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS); +): Promise { + return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), getProjectTrustPromptOptions(cwd)); } async function promptForProjectTrustWithContext( cwd: string, ctx: ProjectTrustContext, -): Promise { +): Promise { + const options = getProjectTrustPromptOptions(cwd); const selected = await ctx.ui.select( formatProjectTrustPrompt(cwd), - PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label), + options.map((option) => option.label), ); - return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value; + return options.find((option) => option.label === selected)?.value; } function createProjectTrustContext(options: { @@ -660,12 +667,17 @@ async function resolveProjectTrusted(options: { if (decision !== null) { return decision; } + const projectTrustSetting = options.settingsManagerForPrompt.getProjectTrustSetting(); + if (projectTrustSetting === "always") { + return true; + } + if (projectTrustSetting === "never") { + return false; + } if (options.projectTrustContext?.hasUI) { const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext); if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } + saveProjectTrustPromptResult(options.trustStore, selected); return selected.trusted; } return false; @@ -676,9 +688,7 @@ async function resolveProjectTrusted(options: { const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } + saveProjectTrustPromptResult(options.trustStore, selected); return selected.trusted; } return false; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028a..b1fa0b2a0 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -12,7 +12,7 @@ import { Text, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; -import type { WarningSettings } from "../../../core/settings-manager.ts"; +import type { ProjectTrustSetting, WarningSettings } from "../../../core/settings-manager.ts"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -31,6 +31,21 @@ const THINKING_DESCRIPTIONS: Record = { xhigh: "Maximum reasoning (~32k tokens)", }; +const PROJECT_TRUST_LABELS: Record = { + ask: "ask when unknown", + always: "always trust (override with -na)", + never: "never trust (override with -a)", +}; + +function projectTrustSettingFromLabel(label: string): ProjectTrustSetting { + for (const [setting, displayLabel] of Object.entries(PROJECT_TRUST_LABELS)) { + if (displayLabel === label) { + return setting as ProjectTrustSetting; + } + } + return "ask"; +} + export interface SettingsConfig { autoCompact: boolean; showImages: boolean; @@ -55,6 +70,7 @@ export interface SettingsConfig { editorPaddingX: number; autocompleteMaxVisible: number; quietStartup: boolean; + projectTrust: ProjectTrustSetting; clearOnShrink: boolean; showTerminalProgress: boolean; warnings: WarningSettings; @@ -83,6 +99,7 @@ export interface SettingsCallbacks { onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; + onProjectTrustChange: (setting: ProjectTrustSetting) => void; onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onWarningsChange: (warnings: WarningSettings) => void; @@ -277,6 +294,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.enableInstallTelemetry ? "true" : "false", values: ["true", "false"], }, + { + id: "project-trust", + label: "Trust projects", + description: "Should unknown projects be trusted to load extensions and agent files?", + currentValue: PROJECT_TRUST_LABELS[config.projectTrust], + values: Object.values(PROJECT_TRUST_LABELS), + }, { id: "double-escape-action", label: "Double-escape action", @@ -512,6 +536,9 @@ export class SettingsSelectorComponent extends Container { case "install-telemetry": callbacks.onEnableInstallTelemetryChange(newValue === "true"); break; + case "project-trust": + callbacks.onProjectTrustChange(projectTrustSettingFromLabel(newValue)); + break; case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; 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 b36647680..f21889cc9 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,51 +1,46 @@ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; -import type { ProjectTrustDecision } from "../../../core/trust-manager.ts"; +import { + getProjectTrustOptions, + type ProjectTrustOption, + type ProjectTrustStoreEntry, +} from "../../../core/trust-manager.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; -interface TrustOption { - label: string; - trusted: boolean; -} +export type TrustSelection = Pick; export interface TrustSelectorOptions { cwd: string; - savedDecision: ProjectTrustDecision; + savedDecision: ProjectTrustStoreEntry | null; projectTrusted: boolean; - onSelect: (trusted: boolean) => void; + onSelect: (selection: TrustSelection) => void; onCancel: () => void; } -const TRUST_OPTIONS: TrustOption[] = [ - { label: "Trust", trusted: true }, - { label: "Do not trust", trusted: false }, -]; - -function formatDecision(decision: ProjectTrustDecision): string { - if (decision === true) { - return "trusted"; +function formatDecision(decision: ProjectTrustStoreEntry | null): string { + if (decision === null) { + return "none"; } - if (decision === false) { - return "untrusted"; - } - return "none"; + return `${decision.decision ? "trusted" : "untrusted"} (${decision.path})`; } export class TrustSelectorComponent extends Container { private selectedIndex: number; private readonly listContainer: Container; - private readonly savedDecision: ProjectTrustDecision; - private readonly onSelectCallback: (trusted: boolean) => void; + private readonly trustOptions: ProjectTrustOption[]; + private readonly savedDecision: ProjectTrustStoreEntry | null; + private readonly onSelectCallback: (selection: TrustSelection) => void; private readonly onCancelCallback: () => void; constructor(options: TrustSelectorOptions) { super(); this.savedDecision = options.savedDecision; + this.trustOptions = getProjectTrustOptions(options.cwd); this.selectedIndex = Math.max( 0, - TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), + this.trustOptions.findIndex((option) => this.isSavedOption(option)), ); this.onSelectCallback = options.onSelect; this.onCancelCallback = options.onCancel; @@ -81,16 +76,24 @@ export class TrustSelectorComponent extends Container { this.updateList(); } + private isSavedOption(option: ProjectTrustOption): boolean { + return ( + option.savedPath !== undefined && + this.savedDecision?.decision === option.trusted && + this.savedDecision.path === option.savedPath + ); + } + private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < TRUST_OPTIONS.length; i++) { - const option = TRUST_OPTIONS[i]; + for (let i = 0; i < this.trustOptions.length; i++) { + const option = this.trustOptions[i]; if (!option) { continue; } const isSelected = i === this.selectedIndex; - const isCurrent = option.trusted === this.savedDecision; + const isCurrent = this.isSavedOption(option); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; const prefix = isSelected ? theme.fg("accent", "→ ") : " "; const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); @@ -104,12 +107,12 @@ export class TrustSelectorComponent extends Container { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { - this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1); + this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { - const selected = TRUST_OPTIONS[this.selectedIndex]; + const selected = this.trustOptions[this.selectedIndex]; if (selected) { - this.onSelectCallback(selected.trusted); + this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates }); } } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f90cffab0..e0515fc44 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3968,6 +3968,7 @@ export class InteractiveMode { editorPaddingX: this.settingsManager.getEditorPaddingX(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), + projectTrust: this.settingsManager.getProjectTrustSetting(), clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), warnings: this.settingsManager.getWarnings(), @@ -4058,6 +4059,9 @@ export class InteractiveMode { onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, + onProjectTrustChange: (setting) => { + this.settingsManager.setProjectTrustSetting(setting); + }, onDoubleEscapeActionChange: (action) => { this.settingsManager.setDoubleEscapeAction(action); }, @@ -4212,17 +4216,17 @@ export class InteractiveMode { private showTrustSelector(): void { const cwd = this.sessionManager.getCwd(); const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); - const savedDecision = trustStore.get(cwd); + const savedDecision = trustStore.getEntry(cwd); this.showSelector((done) => { const selector = new TrustSelectorComponent({ cwd, savedDecision, projectTrusted: this.settingsManager.isProjectTrusted(), - onSelect: (trusted) => { - trustStore.set(cwd, trusted); + onSelect: (selection) => { + trustStore.setMany(selection.updates); done(); this.showStatus( - `Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, ); }, onCancel: () => { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index ea90d3187..5ad83ac75 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -429,7 +429,15 @@ function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boo if (trustOverride !== undefined) { return trustOverride; } - return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true; + if (!hasProjectTrustInputs(cwd)) { + return true; + } + const decision = new ProjectTrustStore(agentDir).get(cwd); + if (decision !== null) { + return decision; + } + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + return settingsManager.getProjectTrustSetting() === "always"; } export async function handleConfigCommand(args: string[]): Promise { diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1fc587c8d..502cc265d 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -102,6 +102,25 @@ describe("package commands", () => { } }); + it("uses projectTrust always for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ projectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("uses remembered project trust for list", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index b28d086a1..849176f2d 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -215,6 +215,29 @@ describe("SettingsManager", () => { }); describe("project trust", () => { + it("should persist the default project trust setting globally", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getProjectTrustSetting()).toBe("ask"); + + manager.setProjectTrustSetting("always"); + await manager.flush(); + + expect(manager.getProjectTrustSetting()).toBe("always"); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"))).toMatchObject({ + projectTrust: "always", + }); + }); + + it("should not let project settings control the default project trust setting", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ projectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ projectTrust: "never" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getProjectTrustSetting()).toBe("always"); + }); + it("should skip project settings when project is not trusted", () => { writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 057db06a2..30c0e7952 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -21,7 +21,10 @@ function createTempDir(): string { return dir; } -async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> { +async function runCli( + args: string[], + globalSettings?: Record, +): Promise<{ stdout: string; stderr: string; code: number | null }> { const tempRoot = createTempDir(); const agentDir = join(tempRoot, "agent"); const projectDir = join(tempRoot, "project"); @@ -52,6 +55,9 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; ), "utf-8", ); + if (globalSettings) { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify(globalSettings, null, 2), "utf-8"); + } return await new Promise((resolvePromise, reject) => { const child = spawn(process.execPath, [cliPath, ...args], { @@ -109,4 +115,14 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).not.toContain("found 0 vulnerabilities"); expect(result.stderr).toContain("Usage:"); }); + + it("uses projectTrust always as the default project trust decision", async () => { + const result = await runCli(["-p", "--help"], { projectTrust: "always" }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("changed 1 package in 471ms"); + expect(result.stderr).toContain("found 0 vulnerabilities"); + expect(result.stderr).toContain("Usage:"); + }); }); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index d91dde49b..6daba3e8f 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; +import { + getProjectTrustPath, + hasProjectConfigDir, + hasProjectTrustInputs, + ProjectTrustStore, +} from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => { 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", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + mkdirSync(childDir, { recursive: true }); + + store.set(parentDir, true); + store.set(childDir, false); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); + + store.setMany([ + { cwd: parentDir, decision: true }, + { cwd: childDir, decision: null }, + ]); + expect(store.get(childDir)).toBe(true); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); }); it("fails loudly without overwriting malformed trust stores", () => { diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts index 65c73c21d..0816cc5a2 100644 --- a/packages/coding-agent/test/trust-selector.test.ts +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => { it("marks the saved trusted decision", () => { const selector = new TrustSelectorComponent({ cwd: "/project", - savedDecision: true, + savedDecision: { path: "/project", decision: true }, projectTrusted: true, onSelect: () => {}, onCancel: () => {}, @@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => { const output = stripAnsi(selector.render(120).join("\n")); - expect(output).toContain("Saved decision: trusted"); + expect(output).toContain("Saved decision: trusted (/project)"); expect(output).toContain("Current session: trusted"); expect(output).toContain("Trust ✓"); expect(output).not.toContain("Do not trust ✓"); @@ -43,6 +43,30 @@ describe("TrustSelectorComponent", () => { selector.handleInput("\n"); - expect(onSelect).toHaveBeenCalledWith(true); + expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ cwd: "/project", decision: true }] }); + }); + + it("adds a trust parent option", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/parent/project", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("Trust parent folder (/parent) ✓"); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith({ + trusted: true, + updates: [ + { cwd: "/parent", decision: true }, + { cwd: "/parent/project", decision: null }, + ], + }); }); });