diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6cd0b184..8d84d266 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e86d8c62..ad6fb655 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -186,6 +186,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/name ` | Set session display name | | `/session` | Show session info (file, ID, messages, tokens, cost) | | `/tree` | Jump to any point in the session and continue from there | +| `/trust` | Save project trust decision for future sessions (restart required) | | `/fork` | Create a new session from a previous user message | | `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Manually compact context, optional custom instructions | @@ -288,6 +289,16 @@ Use `/settings` to modify common options, or edit JSON files directly: 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. + +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. + +`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. + ### Telemetry and update checks Pi has two separate startup features: @@ -303,10 +314,10 @@ Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations desc Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from: - `~/.pi/agent/AGENTS.md` (global) -- Parent directories (walking up from cwd) -- Current directory +- Parent directories (walking up from cwd, only when the project is trusted) +- Current directory (only when the project is trusted) -Use for project instructions, conventions, common commands. All matching files are concatenated. +Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. Disable context file loading with `--no-context-files` (or `-nc`). @@ -512,6 +523,8 @@ pi list # List installed packages pi config # Enable/disable package resources ``` +Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. + ### Modes | Flag | Description | @@ -585,6 +598,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 08f60162..0d63ceb6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -109,7 +109,7 @@ pi -e ./my-extension.ts > **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust. -Extensions are auto-discovered from: +Extensions are auto-discovered from trusted locations. Project-local `.pi/extensions` entries load only after the project is trusted. | Location | Scope | |----------|-------| diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 33527191..1469ea98 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,9 @@ pi update --extension npm:@foo/bar These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). -By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup. +By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. + +Project package commands read project settings only when the project is trusted. Use `--approve` to trust project-local files for one command, or `--no-approve` to ignore them for one command. To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 056d2c9a..00450858 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam Pi loads prompt templates from: - Global: `~/.pi/agent/prompts/*.md` -- Project: `.pi/prompts/*.md` +- Project: `.pi/prompts/*.md` (only after the project is trusted) - Packages: `prompts/` directories or `pi.prompts` entries in `package.json` - Settings: `prompts` array with files or directories - CLI: `--prompt-template ` (repeatable) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 47aecc4a..df2d0bd6 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -9,6 +9,16 @@ Pi uses JSON settings files with project settings overriding global settings. 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. + +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. + +`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. + ## All Settings ### Model & Thinking diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index cb96dd97..d3ffeea8 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -26,7 +26,7 @@ Pi loads skills from: - Global: - `~/.pi/agent/skills/` - `~/.agents/skills/` -- Project: +- Project (only after the project is trusted): - `.pi/skills/` - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - Packages: `skills/` directories or `pi.skills` entries in `package.json` diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 8762943d..c18e954b 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -20,7 +20,7 @@ Pi loads themes from: - Built-in: `dark`, `light` - Global: `~/.pi/agent/themes/*.json` -- Project: `.pi/themes/*.json` +- Project: `.pi/themes/*.json` (only after the project is trusted) - Packages: `themes/` directories or `pi.themes` entries in `package.json` - Settings: `themes` array with files or directories - CLI: `--theme ` (repeatable) diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 2f2a6449..ad9b80e1 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -96,8 +96,8 @@ See [Sessions](sessions.md) and [Compaction](compaction.md) for details. Pi loads `AGENTS.md` or `CLAUDE.md` at startup from: - `~/.pi/agent/AGENTS.md` for global instructions -- parent directories, walking up from the current working directory -- the current directory +- parent directories, walking up from the current working directory when the project is trusted +- the current directory when the project is trusted Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. @@ -110,6 +110,16 @@ Replace the default system prompt with: Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location. +### 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. + +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. + +`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. + ## Exporting and Sharing Sessions Use `/export [file]` to write a session to HTML. @@ -138,7 +148,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). +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. See [Pi Packages](packages.md) for package sources and security notes. @@ -219,6 +229,8 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | +| `-a`, `--approve` | Trust project-local files for this run | +| `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 959484da..0258c0c1 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -46,6 +46,7 @@ export interface Args { listModels?: string | true; offline?: boolean; verbose?: boolean; + projectTrustOverride?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -176,6 +177,10 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--verbose") { result.verbose = true; + } else if (arg === "--approve" || arg === "-a") { + result.projectTrustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + result.projectTrustOverride = false; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -225,8 +230,10 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove ${APP_NAME} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list List installed extensions from settings - ${APP_NAME} config Open TUI to enable/disable package resources + ${APP_NAME} list [--approve|--no-approve] + List installed extensions from settings + ${APP_NAME} config [--no-approve] + Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list ${chalk.bold("Options:")} @@ -266,6 +273,8 @@ ${chalk.bold("Options:")} --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) + --approve, -a Trust project-local files for this run + --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index ac35a273..5120c9ad 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -963,6 +963,7 @@ export class DefaultPackageManager implements PackageManager { async install(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("install", source, `Installing ${source}...`, async () => { if (parsed.type === "npm") { await this.installNpm(parsed, scope, false); @@ -991,6 +992,7 @@ export class DefaultPackageManager implements PackageManager { async remove(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); await this.withProgress("remove", source, `Removing ${source}...`, async () => { if (parsed.type === "npm") { await this.uninstallNpm(parsed, scope); @@ -1673,6 +1675,12 @@ export class DefaultPackageManager implements PackageManager { return { name, version }; } + private assertProjectTrustedForScope(scope: SourceScope): void { + if (scope === "project" && !this.settingsManager.isProjectTrusted()) { + throw new Error("Project is not trusted; refusing to access project package storage"); + } + } + private getNpmCommand(): { command: string; args: string[] } { const configuredCommand = this.settingsManager.getNpmCommand(); if (!configuredCommand || configuredCommand.length === 0) { @@ -1893,6 +1901,7 @@ export class DefaultPackageManager implements PackageManager { return this.getTemporaryDir("npm"); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm"); } return join(this.agentDir, "npm"); @@ -1933,6 +1942,7 @@ export class DefaultPackageManager implements PackageManager { return join(this.getTemporaryDir("npm"), "node_modules", source.name); } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); } return join(this.agentDir, "npm", "node_modules", source.name); @@ -1971,6 +1981,7 @@ export class DefaultPackageManager implements PackageManager { return undefined; } if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME, "git"); } return join(this.agentDir, "git"); @@ -1996,6 +2007,7 @@ export class DefaultPackageManager implements PackageManager { private getBaseDirForScope(scope: SourceScope): string { if (scope === "project") { + this.assertProjectTrustedForScope(scope); return join(this.cwd, CONFIG_DIR_NAME); } if (scope === "user") { @@ -2257,9 +2269,10 @@ 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 projectTrusted = this.settingsManager.isProjectTrusted(); + const projectAgentsSkillDirs = projectTrusted + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + : []; const addResources = ( resourceType: ResourceType, @@ -2275,23 +2288,25 @@ export class DefaultPackageManager implements PackageManager { } }; - // Project extensions from .pi/ - addResources( - "extensions", - collectAutoExtensionEntries(projectDirs.extensions), - projectMetadata, - projectOverrides.extensions, - projectBaseDir, - ); + if (projectTrusted) { + // 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, + ); + } // Project skills from .agents/ (each with its own baseDir) for (const agentsSkillsDir of projectAgentsSkillDirs) { @@ -2309,20 +2324,22 @@ export class DefaultPackageManager implements PackageManager { ); } - addResources( - "prompts", - collectAutoPromptEntries(projectDirs.prompts), - projectMetadata, - projectOverrides.prompts, - projectBaseDir, - ); - addResources( - "themes", - collectAutoThemeEntries(projectDirs.themes), - projectMetadata, - projectOverrides.themes, - projectBaseDir, - ); + if (projectTrusted) { + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } // User extensions from ~/.pi/agent/ addResources( diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index eb043cd5..1221366a 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -75,6 +75,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } export function loadProjectContextFiles(options: { cwd: string; agentDir: string; + projectTrusted?: boolean; }): Array<{ path: string; content: string }> { const resolvedCwd = resolvePath(options.cwd); const resolvedAgentDir = resolvePath(options.agentDir); @@ -88,27 +89,29 @@ export function loadProjectContextFiles(options: { seenPaths.add(globalContext.path); } - const ancestorContextFiles: Array<{ path: string; content: string }> = []; + if (options.projectTrusted !== false) { + const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = resolvedCwd; - const root = resolve("/"); + let currentDir = resolvedCwd; + const root = resolve("/"); - while (true) { - const contextFile = loadContextFileFromDir(currentDir); - if (contextFile && !seenPaths.has(contextFile.path)) { - ancestorContextFiles.unshift(contextFile); - seenPaths.add(contextFile.path); + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); + } + + if (currentDir === root) break; + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; } - if (currentDir === root) break; - - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; + contextFiles.push(...ancestorContextFiles); } - contextFiles.push(...ancestorContextFiles); - return contextFiles; } @@ -466,7 +469,13 @@ export class DefaultResourceLoader implements ResourceLoader { } const agentsFiles = { - agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }), + agentsFiles: this.noContextFiles + ? [] + : loadProjectContextFiles({ + cwd: this.cwd, + agentDir: this.agentDir, + projectTrusted: this.settingsManager.isProjectTrusted(), + }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; @@ -852,7 +861,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } @@ -866,7 +875,7 @@ export class DefaultResourceLoader implements ResourceLoader { private discoverAppendSystemPromptFile(): string | undefined { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (existsSync(projectPath)) { + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 910b68aa..2ef32d6d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -159,6 +159,10 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde export type SettingsScope = "global" | "project"; +export interface SettingsManagerCreateOptions { + projectTrusted?: boolean; +} + export interface SettingsStorage { withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; } @@ -259,6 +263,7 @@ export class SettingsManager { private globalSettings: Settings; private projectSettings: Settings; private settings: Settings; + private projectTrusted: boolean; private modifiedFields = new Set(); // Track global fields modified during session private modifiedNestedFields = new Map>(); // Track global nested field modifications private modifiedProjectFields = new Set(); // Track project fields modified during session @@ -275,10 +280,12 @@ export class SettingsManager { globalLoadError: Error | null = null, projectLoadError: Error | null = null, initialErrors: SettingsError[] = [], + projectTrusted = true, ) { this.storage = storage; this.globalSettings = initialGlobal; this.projectSettings = initialProject; + this.projectTrusted = projectTrusted; this.globalSettingsLoadError = globalLoadError; this.projectSettingsLoadError = projectLoadError; this.errors = [...initialErrors]; @@ -286,15 +293,20 @@ export class SettingsManager { } /** Create a SettingsManager that loads from files */ - static create(cwd: string, agentDir: string = getAgentDir()): SettingsManager { + static create( + cwd: string, + agentDir: string = getAgentDir(), + options: SettingsManagerCreateOptions = {}, + ): SettingsManager { const storage = new FileSettingsStorage(cwd, agentDir); - return SettingsManager.fromStorage(storage); + return SettingsManager.fromStorage(storage, options); } /** Create a SettingsManager from an arbitrary storage backend */ - static fromStorage(storage: SettingsStorage): SettingsManager { + static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + const projectTrusted = options.projectTrusted ?? true; const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); - const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); const initialErrors: SettingsError[] = []; if (globalLoad.error) { initialErrors.push({ scope: "global", error: globalLoad.error }); @@ -310,6 +322,7 @@ export class SettingsManager { globalLoad.error, projectLoad.error, initialErrors, + projectTrusted, ); } @@ -321,7 +334,11 @@ export class SettingsManager { return SettingsManager.fromStorage(storage); } - private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope): Settings { + private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings { + if (scope === "project" && !projectTrusted) { + return {}; + } + let content: string | undefined; storage.withLock(scope, (current) => { content = current; @@ -338,9 +355,10 @@ export class SettingsManager { private static tryLoadFromStorage( storage: SettingsStorage, scope: SettingsScope, + projectTrusted = true, ): { settings: Settings; error: Error | null } { try { - return { settings: SettingsManager.loadFromStorage(storage, scope), error: null }; + return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; } catch (error) { return { settings: {}, error: error as Error }; } @@ -416,6 +434,35 @@ export class SettingsManager { return structuredClone(this.projectSettings); } + isProjectTrusted(): boolean { + return this.projectTrusted; + } + + setProjectTrusted(trusted: boolean): void { + if (this.projectTrusted === trusted) { + return; + } + + this.projectTrusted = trusted; + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + if (!trusted) { + this.projectSettings = {}; + this.projectSettingsLoadError = null; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + return; + } + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted); + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = projectLoad.error; + if (projectLoad.error) { + this.recordError("project", projectLoad.error); + } + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + async reload(): Promise { await this.writeQueue; const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); @@ -432,7 +479,7 @@ export class SettingsManager { this.modifiedProjectFields.clear(); this.modifiedProjectNestedFields.clear(); - const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted); if (!projectLoad.error) { this.projectSettings = projectLoad.settings; this.projectSettingsLoadError = null; @@ -471,6 +518,12 @@ export class SettingsManager { } } + private assertProjectTrustedForWrite(): void { + if (!this.projectTrusted) { + throw new Error("Project is not trusted; refusing to write project settings"); + } + } + private recordError(scope: SettingsScope, error: unknown): void { const normalizedError = error instanceof Error ? error : new Error(String(error)); this.errors.push({ scope, error: normalizedError }); @@ -490,6 +543,9 @@ export class SettingsManager { private enqueueWrite(scope: SettingsScope, task: () => void): void { this.writeQueue = this.writeQueue .then(() => { + if (scope === "project") { + this.assertProjectTrustedForWrite(); + } task(); this.clearModifiedScope(scope); }) @@ -554,6 +610,7 @@ export class SettingsManager { } private saveProjectSettings(settings: Settings): void { + this.assertProjectTrustedForWrite(); this.projectSettings = structuredClone(settings); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); @@ -569,6 +626,14 @@ export class SettingsManager { }); } + private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void { + this.assertProjectTrustedForWrite(); + const projectSettings = structuredClone(this.projectSettings); + update(projectSettings); + this.markProjectModified(field); + this.saveProjectSettings(projectSettings); + } + async flush(): Promise { await this.writeQueue; } @@ -839,10 +904,9 @@ export class SettingsManager { } setProjectPackages(packages: PackageSource[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.packages = packages; - this.markProjectModified("packages"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("packages", (settings) => { + settings.packages = packages; + }); } getExtensionPaths(): string[] { @@ -856,10 +920,9 @@ export class SettingsManager { } setProjectExtensionPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.extensions = paths; - this.markProjectModified("extensions"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("extensions", (settings) => { + settings.extensions = paths; + }); } getSkillPaths(): string[] { @@ -873,10 +936,9 @@ export class SettingsManager { } setProjectSkillPaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.skills = paths; - this.markProjectModified("skills"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("skills", (settings) => { + settings.skills = paths; + }); } getPromptTemplatePaths(): string[] { @@ -890,10 +952,9 @@ export class SettingsManager { } setProjectPromptTemplatePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.prompts = paths; - this.markProjectModified("prompts"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("prompts", (settings) => { + settings.prompts = paths; + }); } getThemePaths(): string[] { @@ -907,10 +968,9 @@ export class SettingsManager { } setProjectThemePaths(paths: string[]): void { - const projectSettings = structuredClone(this.projectSettings); - projectSettings.themes = paths; - this.markProjectModified("themes"); - this.saveProjectSettings(projectSettings); + this.updateProjectSettings("themes", (settings) => { + settings.themes = paths; + }); } getEnableSkillCommands(): boolean { diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index e52892c8..77dd79aa 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "trust", description: "Save project trust decision for future sessions" }, { name: "login", description: "Configure provider authentication" }, { name: "logout", description: "Remove provider authentication" }, { name: "new", description: "Start a new session" }, diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts new file mode 100644 index 00000000..59abcc92 --- /dev/null +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -0,0 +1,153 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import lockfile from "proper-lockfile"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; + +export type ProjectTrustDecision = boolean | null; + +type TrustFile = Record; + +const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; + +function normalizeCwd(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function readTrustFile(path: string): TrustFile { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read trust store ${path}: ${message}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${path}: expected an object`); + } + + const data: TrustFile = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value !== true && value !== false && value !== null) { + throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`); + } + data[key] = value; + } + return data; +} + +function writeTrustFile(path: string, data: TrustFile): void { + const sorted: TrustFile = {}; + for (const key of Object.keys(data).sort()) { + const value = data[key]; + if (value === true || value === false || value === null) { + sorted[key] = value; + } + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +function acquireTrustLockSync(path: string): () => void { + const trustDir = dirname(path); + mkdirSync(trustDir, { recursive: true }); + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing trust store callers to async. + } + } + } + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to acquire trust store lock"); +} + +function withTrustFileLock(path: string, fn: () => T): T { + const release = acquireTrustLockSync(path); + try { + return fn(); + } finally { + release(); + } +} + +export function hasProjectTrustInputs(cwd: string): boolean { + let currentDir = resolvePath(cwd); + if (existsSync(join(currentDir, CONFIG_DIR_NAME))) { + return true; + } + + const root = resolve("/"); + while (true) { + for (const filename of CONTEXT_FILE_NAMES) { + if (existsSync(join(currentDir, filename))) { + return true; + } + } + if (existsSync(join(currentDir, ".agents", "skills"))) { + return true; + } + + if (currentDir === root) { + return false; + } + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) { + return false; + } + currentDir = parentDir; + } +} + +export class ProjectTrustStore { + private trustPath: string; + + constructor(agentDir: string) { + this.trustPath = join(resolvePath(agentDir), "trust.json"); + } + + get(cwd: string): ProjectTrustDecision { + return withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + const value = data[normalizeCwd(cwd)]; + return value === true || value === false ? value : null; + }); + } + + set(cwd: string, decision: ProjectTrustDecision): void { + withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + 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 0cdfc726..1f9b3a19 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -219,6 +219,7 @@ export { type PackageSource, type RetrySettings, SettingsManager, + type SettingsManagerCreateOptions, } from "./core/settings-manager.ts"; // Skills export { @@ -281,6 +282,7 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; +export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } 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 353e4815..a3fbb719 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -40,6 +40,7 @@ import { import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; @@ -436,10 +437,11 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -async function promptForMissingSessionCwd( - issue: SessionCwdIssue, +async function showStartupSelector( settingsManager: SettingsManager, -): Promise { + title: string, + options: Array<{ label: string; value: T }>, +): Promise { initTheme(settingsManager.getTheme()); setKeybindings(KeybindingsManager.create()); @@ -448,7 +450,7 @@ async function promptForMissingSessionCwd( ui.setClearOnShrink(settingsManager.getClearOnShrink()); let settled = false; - const finish = (result: string | undefined) => { + const finish = (result: T | undefined) => { if (settled) { return; } @@ -458,9 +460,9 @@ async function promptForMissingSessionCwd( }; const selector = new ExtensionSelectorComponent( - formatMissingSessionCwdPrompt(issue), - ["Continue", "Cancel"], - (option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), + title, + options.map((option) => option.label), + (option) => finish(options.find((entry) => entry.label === option)?.value), () => finish(undefined), { tui: ui }, ); @@ -470,6 +472,69 @@ async function promptForMissingSessionCwd( }); } +async function promptForMissingSessionCwd( + issue: SessionCwdIssue, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [ + { label: "Continue", value: issue.fallbackCwd }, + { label: "Cancel", value: undefined }, + ]); +} + +interface ProjectTrustPromptResult { + trusted: boolean; + remember: boolean; +} + +async function promptForProjectTrust( + cwd: string, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector( + settingsManager, + `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.`, + [ + { 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 } }, + ], + ); +} + +async function resolveProjectTrusted(options: { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + appMode: AppMode; + settingsManagerForPrompt: SettingsManager; +}): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasProjectTrustInputs(options.cwd)) { + return true; + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + if (options.appMode !== "interactive") { + return false; + } + + const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); + if (selected !== undefined) { + if (selected.remember) { + options.trustStore.set(options.cwd, selected.trusted); + } + return selected.trusted; + } + return false; +} + export interface MainOptions { extensionFactories?: ExtensionFactory[]; } @@ -581,6 +646,16 @@ export async function main(args: string[], options?: MainOptions) { } time("createSessionManager"); + const trustStore = new ProjectTrustStore(agentDir); + const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; + const projectTrustedForSession = await resolveProjectTrusted({ + cwd: sessionManager.getCwd(), + trustStore, + trustOverride: parsed.projectTrustOverride, + appMode: trustPromptMode, + settingsManagerForPrompt: startupSettingsManager, + }); + const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); @@ -592,10 +667,16 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, sessionStartEvent, }) => { + const projectTrusted = + cwd === sessionManager.getCwd() + ? projectTrustedForSession + : (parsed.projectTrustOverride ?? (!hasProjectTrustInputs(cwd) || trustStore.get(cwd) === true)); + const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); const services = await createAgentSessionServices({ cwd, agentDir, authStorage, + settingsManager: runtimeSettingsManager, extensionFlagValues: parsed.unknownFlags, resourceLoaderOptions: { additionalExtensionPaths: resolvedExtensionPaths, diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index d9b5d773..fa47d642 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -27,6 +27,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts"; export { ThinkingSelectorComponent } from "./thinking-selector.ts"; export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; export { TreeSelectorComponent } from "./tree-selector.ts"; +export { TrustSelectorComponent } from "./trust-selector.ts"; export { UserMessageComponent } from "./user-message.ts"; export { UserMessageSelectorComponent } from "./user-message-selector.ts"; export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts new file mode 100644 index 00000000..b3664768 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -0,0 +1,118 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import type { ProjectTrustDecision } 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 interface TrustSelectorOptions { + cwd: string; + savedDecision: ProjectTrustDecision; + projectTrusted: boolean; + onSelect: (trusted: boolean) => 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"; + } + if (decision === false) { + return "untrusted"; + } + return "none"; +} + +export class TrustSelectorComponent extends Container { + private selectedIndex: number; + private readonly listContainer: Container; + private readonly savedDecision: ProjectTrustDecision; + private readonly onSelectCallback: (trusted: boolean) => void; + private readonly onCancelCallback: () => void; + + constructor(options: TrustSelectorOptions) { + super(); + + this.savedDecision = options.savedDecision; + this.selectedIndex = Math.max( + 0, + TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), + ); + this.onSelectCallback = options.onSelect; + this.onCancelCallback = options.onCancel; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); + 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.savedDecision)}`), 1, 0)); + this.addChild( + new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), + ); + this.addChild(new Spacer(1)); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "save") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + for (let i = 0; i < TRUST_OPTIONS.length; i++) { + const option = TRUST_OPTIONS[i]; + if (!option) { + continue; + } + + const isSelected = i === this.selectedIndex; + const isCurrent = option.trusted === this.savedDecision; + 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); + this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + 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.updateList(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + const selected = TRUST_OPTIONS[this.selectedIndex]; + if (selected) { + this.onSelectCallback(selected.trusted); + } + } 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 5ed40caf..5e0ecaf9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -120,6 +121,7 @@ import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; +import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { @@ -2564,6 +2566,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/trust") { + this.showTrustSelector(); + this.editor.setText(""); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -3226,6 +3233,7 @@ export class InteractiveMode { updateFooter: true, populateHistory: true, }); + this.renderProjectTrustWarningIfNeeded(); // Show compaction info if session was compacted const allEntries = this.sessionManager.getEntries(); @@ -3236,6 +3244,26 @@ export class InteractiveMode { } } + private renderProjectTrustWarningIfNeeded(): void { + if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) { + return; + } + + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild( + new Text( + theme.fg( + "warning", + "This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.", + ), + 1, + 0, + ), + ); + } + async getUserInput(): Promise { const queuedInput = this.pendingUserInputs.shift(); if (queuedInput !== undefined) { @@ -4135,6 +4163,31 @@ export class InteractiveMode { } } + private showTrustSelector(): void { + const cwd = this.sessionManager.getCwd(); + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + const savedDecision = trustStore.get(cwd); + this.showSelector((done) => { + const selector = new TrustSelectorComponent({ + cwd, + savedDecision, + projectTrusted: this.settingsManager.isProjectTrusted(), + onSelect: (trusted) => { + trustStore.set(cwd, trusted); + done(); + this.showStatus( + `Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + ); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }); + return { component: selector, focus: selector }; + }); + } + private showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { const selector = new ModelSelectorComponent( diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index f61d4ff2..ea90d318 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -14,6 +14,7 @@ import { } from "./config.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts"; +import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { @@ -48,6 +49,7 @@ interface PackageCommandOptions { updateTarget?: UpdateTarget; local: boolean; force: boolean; + projectTrustOverride?: boolean; help: boolean; invalidOption?: string; invalidArgument?: string; @@ -68,13 +70,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string) function getPackageCommandUsage(command: PackageCommand): string { switch (command) { case "install": - return `${APP_NAME} install [-l]`; + return `${APP_NAME} install [-l] [--approve|--no-approve]`; case "remove": - return `${APP_NAME} remove [-l]`; + return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--approve|--no-approve] [--force]`; case "list": - return `${APP_NAME} list`; + return `${APP_NAME} list [--approve|--no-approve]`; } } @@ -87,7 +89,9 @@ function printPackageCommandHelp(command: PackageCommand): void { Install a package and add it to settings. Options: - -l, --local Install project-locally (.pi/settings.json) + -l, --local Install project-locally (.pi/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} install npm:@foo/bar @@ -107,7 +111,9 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (.pi/settings.json) + -l, --local Remove from project settings (.pi/settings.json) + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command Examples: ${APP_NAME} remove npm:@foo/bar @@ -125,6 +131,8 @@ Options: --self Update pi only --extensions Update installed packages only --extension Update one package only + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command --force Reinstall pi even if the current version is latest Short forms: @@ -139,6 +147,10 @@ Short forms: ${getPackageCommandUsage("list")} List installed packages from user and project settings. + +Options: + -a, --approve Trust project-local files for this command + -na, --no-approve Ignore project-local files for this command `); return; } @@ -158,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; + let projectTrustOverride: boolean | undefined; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -202,6 +215,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--approve" || arg === "-a") { + projectTrustOverride = true; + continue; + } + + if (arg === "--no-approve" || arg === "-na") { + projectTrustOverride = false; + continue; + } + if (arg === "--force") { if (command === "update") { force = true; @@ -280,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined updateTarget, local, force, + projectTrustOverride, help, invalidOption, invalidArgument, @@ -389,6 +413,25 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } +function parseProjectTrustOverride(args: readonly string[]): boolean | undefined { + let trustOverride: boolean | undefined; + for (const arg of args) { + if (arg === "--approve" || arg === "-a") { + trustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + trustOverride = false; + } + } + return trustOverride; +} + +function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean { + if (trustOverride !== undefined) { + return trustOverride; + } + return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true; +} + export async function handleConfigCommand(args: string[]): Promise { if (args[0] !== "config") { return false; @@ -396,7 +439,8 @@ export async function handleConfigCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const projectTrusted = parseProjectTrustOverride(args) ?? true; + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -460,7 +504,14 @@ export async function handlePackageCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); + const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; + const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride); + if (!projectTrusted && writesProjectPackageConfig) { + console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); + process.exitCode = 1; + return true; + } + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 78fd832c..8591974d 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -293,6 +293,28 @@ describe("parseArgs", () => { }); }); + describe("project approval flags", () => { + test("parses --approve", () => { + const result = parseArgs(["--approve"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses -a shorthand", () => { + const result = parseArgs(["-a"]); + expect(result.projectTrustOverride).toBe(true); + }); + + test("parses --no-approve", () => { + const result = parseArgs(["--no-approve"]); + expect(result.projectTrustOverride).toBe(false); + }); + + test("parses -na shorthand", () => { + const result = parseArgs(["-na"]); + expect(result.projectTrustOverride).toBe(false); + }); + }); + describe("--verbose flag", () => { test("parses --verbose flag", () => { const result = parseArgs(["--verbose"]); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 5e4a1a48..1fc587c8 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", () => { @@ -85,6 +86,103 @@ describe("package commands", () => { expect(removedSettings.packages ?? []).toHaveLength(0); }); + it("skips untrusted project package settings", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + 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("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + } 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"] })); + 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("overrides remembered trust for list with --no-approve", 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", "--no-approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("approves project trust for list with --approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--approve"])).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 is untrusted", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config."); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("allows local package install to initialize fresh project settings", async () => { + await main(["install", "-l", packageDir]); + + const settingsPath = join(projectDir, ".pi", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; + expect(settings.packages?.length).toBe(1); + const stored = settings.packages?.[0] ?? ""; + expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir)); + expect(process.exitCode).toBeUndefined(); + }); + it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -111,7 +209,7 @@ describe("package commands", () => { const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain('Unknown option --unknown for "install".'); - expect(stderr).toContain('Use "pi --help" or "pi install [-l]".'); + expect(stderr).toContain('Use "pi --help" or "pi install [-l] [--approve|--no-approve]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 693f05c9..88dd5895 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -329,6 +329,52 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); + it("should skip project resources when project is not trusted", async () => { + const piDir = join(cwd, ".pi"); + const extensionsDir = join(piDir, "extensions"); + const skillDir = join(piDir, "skills", "project-skill"); + const promptsDir = join(piDir, "prompts"); + const themesDir = join(piDir, "themes"); + mkdirSync(extensionsDir, { recursive: true }); + mkdirSync(skillDir, { recursive: true }); + mkdirSync(promptsDir, { recursive: true }); + mkdirSync(themesDir, { recursive: true }); + writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt."); + writeFileSync(join(agentDir, "SYSTEM.md"), "Global system prompt."); + writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions"); + writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); + writeFileSync(join(extensionsDir, "project.ts"), `throw new Error("should not load");`); + writeFileSync( + join(skillDir, "SKILL.md"), + `--- +name: project-skill +description: Project skill +--- +Project skill content`, + ); + writeFileSync(join(promptsDir, "project.md"), "Project prompt"); + const themeData = JSON.parse( + readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), + ) as { name: string }; + themeData.name = "project-theme"; + writeFileSync(join(themesDir, "project.json"), JSON.stringify(themeData, null, 2)); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + + const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("Global system prompt."); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe( + true, + ); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false); + expect(loader.getExtensions().extensions).toHaveLength(0); + expect(loader.getExtensions().errors).toEqual([]); + expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); + expect(loader.getPrompts().prompts.some((prompt) => prompt.name === "project")).toBe(false); + expect(loader.getThemes().themes.some((theme) => theme.name === "project-theme")).toBe(false); + }); + it("should discover APPEND_SYSTEM.md", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index a503e3cc..b28d086a 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -214,6 +214,44 @@ describe("SettingsManager", () => { }); }); + describe("project trust", () => { + 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" })); + + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(manager.isProjectTrusted()).toBe(false); + expect(manager.getTheme()).toBe("global"); + expect(manager.getProjectSettings()).toEqual({}); + }); + + it("should reload project settings after trust changes to true", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + manager.setProjectTrusted(true); + + expect(manager.isProjectTrusted()).toBe(true); + expect(manager.getTheme()).toBe("project"); + }); + + it("should fail project settings writes when project is not trusted", async () => { + const projectSettingsPath = join(projectDir, ".pi", "settings.json"); + writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] })); + const manager = SettingsManager.create(projectDir, agentDir, { projectTrusted: false }); + + expect(() => manager.setProjectPackages(["npm:new"])).toThrow( + "Project is not trusted; refusing to write project settings", + ); + await manager.flush(); + + expect(manager.getProjectSettings()).toEqual({}); + expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); + }); + }); + describe("project settings directory creation", () => { it("should not create .pi folder when only reading project settings", () => { // Create agent dir with global settings, but NO .pi folder in project diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index f6c426a1..057db06a 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -80,8 +80,8 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { - it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => { - const result = await runCli(["--mode", "json", "--help"]); + it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["--mode", "json", "--help", "--approve"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); @@ -90,13 +90,23 @@ describe("stdout cleanliness in non-interactive modes", () => { expect(result.stderr).toContain("Usage:"); }); - it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => { + it("keeps stdout empty for -p --help while routing trusted startup chatter to stderr", async () => { + const result = await runCli(["-p", "--help", "--approve"]); + + 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:"); + }); + + it("ignores untrusted project package installs for help", async () => { const result = await runCli(["-p", "--help"]); 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).not.toContain("changed 1 package in 471ms"); + expect(result.stderr).not.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 new file mode 100644 index 00000000..3bb01634 --- /dev/null +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -0,0 +1,60 @@ +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 { hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; + +describe("ProjectTrustStore", () => { + let tempDir: string; + let agentDir: string; + let cwd: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `trust-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + cwd = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stores decisions per cwd", () => { + const store = new ProjectTrustStore(agentDir); + + expect(store.get(cwd)).toBeNull(); + store.set(cwd, true); + expect(store.get(cwd)).toBe(true); + store.set(cwd, false); + expect(store.get(cwd)).toBe(false); + store.set(cwd, null); + expect(store.get(cwd)).toBeNull(); + }); + + 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); + + 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"); + }); + + it("detects project trust inputs", () => { + expect(hasProjectTrustInputs(cwd)).toBe(false); + + mkdirSync(join(cwd, ".pi"), { recursive: 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(true); + rmSync(join(cwd, "AGENTS.md"), { force: true }); + + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); + expect(hasProjectTrustInputs(cwd)).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts new file mode 100644 index 00000000..65c73c21 --- /dev/null +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -0,0 +1,48 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { TrustSelectorComponent } from "../src/modes/interactive/components/trust-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +describe("TrustSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("marks the saved trusted decision", () => { + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: true, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted"); + expect(output).toContain("Current session: trusted"); + expect(output).toContain("Trust ✓"); + expect(output).not.toContain("Do not trust ✓"); + }); + + it("selects a trust decision", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/project", + savedDecision: null, + projectTrusted: false, + onSelect, + onCancel: () => {}, + }); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith(true); + }); +});