feat(coding-agent): add project trust gating

This commit is contained in:
Mario Zechner
2026-06-05 10:51:20 +02:00
parent db594d3a59
commit 89a92207f1
28 changed files with 1029 additions and 112 deletions

View File

@@ -4,6 +4,7 @@
### Added ### 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. - Added the latest prompt cache hit rate to the interactive footer.
### Fixed ### Fixed

View File

@@ -186,6 +186,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/name <name>` | Set session display name | | `/name <name>` | Set session display name |
| `/session` | Show session info (file, ID, messages, tokens, cost) | | `/session` | Show session info (file, ID, messages, tokens, cost) |
| `/tree` | Jump to any point in the session and continue from there | | `/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 | | `/fork` | Create a new session from a previous user message |
| `/clone` | Duplicate the current active branch into a new session | | `/clone` | Duplicate the current active branch into a new session |
| `/compact [prompt]` | Manually compact context, optional custom instructions | | `/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. 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 ### Telemetry and update checks
Pi has two separate startup features: 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 loads `AGENTS.md` (or `CLAUDE.md`) at startup from:
- `~/.pi/agent/AGENTS.md` (global) - `~/.pi/agent/AGENTS.md` (global)
- Parent directories (walking up from cwd) - Parent directories (walking up from cwd, only when the project is trusted)
- Current directory - 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`). 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 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 ### Modes
| Flag | Description | | Flag | Description |
@@ -585,6 +598,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) | | `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | Append to system prompt | | `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup | | `--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 | | `-h`, `--help` | Show help |
| `-v`, `--version` | Show version | | `-v`, `--version` | Show version |

View File

@@ -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. > **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 | | Location | Scope |
|----------|-------| |----------|-------|

View File

@@ -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). 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: To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:

View File

@@ -9,7 +9,7 @@ Prompt templates are Markdown snippets that expand into full prompts. Type `/nam
Pi loads prompt templates from: Pi loads prompt templates from:
- Global: `~/.pi/agent/prompts/*.md` - 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` - Packages: `prompts/` directories or `pi.prompts` entries in `package.json`
- Settings: `prompts` array with files or directories - Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable) - CLI: `--prompt-template <path>` (repeatable)

View File

@@ -9,6 +9,16 @@ Pi uses JSON settings files with project settings overriding global settings.
Edit directly or use `/settings` for common options. 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 ## All Settings
### Model & Thinking ### Model & Thinking

View File

@@ -26,7 +26,7 @@ Pi loads skills from:
- Global: - Global:
- `~/.pi/agent/skills/` - `~/.pi/agent/skills/`
- `~/.agents/skills/` - `~/.agents/skills/`
- Project: - Project (only after the project is trusted):
- `.pi/skills/` - `.pi/skills/`
- `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - `.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` - Packages: `skills/` directories or `pi.skills` entries in `package.json`

View File

@@ -20,7 +20,7 @@ Pi loads themes from:
- Built-in: `dark`, `light` - Built-in: `dark`, `light`
- Global: `~/.pi/agent/themes/*.json` - 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` - Packages: `themes/` directories or `pi.themes` entries in `package.json`
- Settings: `themes` array with files or directories - Settings: `themes` array with files or directories
- CLI: `--theme <path>` (repeatable) - CLI: `--theme <path>` (repeatable)

View File

@@ -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 loads `AGENTS.md` or `CLAUDE.md` at startup from:
- `~/.pi/agent/AGENTS.md` for global instructions - `~/.pi/agent/AGENTS.md` for global instructions
- parent directories, walking up from the current working directory - parent directories, walking up from the current working directory when the project is trusted
- the current directory - 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`. 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. 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 ## Exporting and Sharing Sessions
Use `/export [file]` to write a session to HTML. Use `/export [file]` to write a session to HTML.
@@ -138,7 +148,7 @@ pi list # List installed packages
pi config # Enable/disable package resources 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. See [Pi Packages](packages.md) for package sources and security notes.
@@ -219,6 +229,8 @@ pi --no-extensions -e ./my-extension.ts
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended | | `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | Append to system prompt | | `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup | | `--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 | | `-h`, `--help` | Show help |
| `-v`, `--version` | Show version | | `-v`, `--version` | Show version |

View File

@@ -46,6 +46,7 @@ export interface Args {
listModels?: string | true; listModels?: string | true;
offline?: boolean; offline?: boolean;
verbose?: boolean; verbose?: boolean;
projectTrustOverride?: boolean;
messages: string[]; messages: string[];
fileArgs: string[]; fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */ /** 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") { } else if (arg === "--verbose") {
result.verbose = true; 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") { } else if (arg === "--offline") {
result.offline = true; result.offline = true;
} else if (arg.startsWith("@")) { } else if (arg.startsWith("@")) {
@@ -225,8 +230,10 @@ ${chalk.bold("Commands:")}
${APP_NAME} remove <source> [-l] Remove extension source from settings ${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove ${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions ${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list List installed extensions from settings ${APP_NAME} list [--approve|--no-approve]
${APP_NAME} config Open TUI to enable/disable package resources List installed extensions from settings
${APP_NAME} config [--no-approve]
Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list ${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
${chalk.bold("Options:")} ${chalk.bold("Options:")}
@@ -266,6 +273,8 @@ ${chalk.bold("Options:")}
--export <file> Export session file to HTML and exit --export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search) --list-models [search] List available models (with optional fuzzy search)
--verbose Force verbose startup (overrides quietStartup setting) --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) --offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help --help, -h Show this help
--version, -v Show version number --version, -v Show version number

View File

@@ -963,6 +963,7 @@ export class DefaultPackageManager implements PackageManager {
async install(source: string, options?: { local?: boolean }): Promise<void> { async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source); const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user"; const scope: SourceScope = options?.local ? "project" : "user";
this.assertProjectTrustedForScope(scope);
await this.withProgress("install", source, `Installing ${source}...`, async () => { await this.withProgress("install", source, `Installing ${source}...`, async () => {
if (parsed.type === "npm") { if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false); await this.installNpm(parsed, scope, false);
@@ -991,6 +992,7 @@ export class DefaultPackageManager implements PackageManager {
async remove(source: string, options?: { local?: boolean }): Promise<void> { async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source); const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user"; const scope: SourceScope = options?.local ? "project" : "user";
this.assertProjectTrustedForScope(scope);
await this.withProgress("remove", source, `Removing ${source}...`, async () => { await this.withProgress("remove", source, `Removing ${source}...`, async () => {
if (parsed.type === "npm") { if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope); await this.uninstallNpm(parsed, scope);
@@ -1673,6 +1675,12 @@ export class DefaultPackageManager implements PackageManager {
return { name, version }; 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[] } { private getNpmCommand(): { command: string; args: string[] } {
const configuredCommand = this.settingsManager.getNpmCommand(); const configuredCommand = this.settingsManager.getNpmCommand();
if (!configuredCommand || configuredCommand.length === 0) { if (!configuredCommand || configuredCommand.length === 0) {
@@ -1893,6 +1901,7 @@ export class DefaultPackageManager implements PackageManager {
return this.getTemporaryDir("npm"); return this.getTemporaryDir("npm");
} }
if (scope === "project") { if (scope === "project") {
this.assertProjectTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "npm"); return join(this.cwd, CONFIG_DIR_NAME, "npm");
} }
return join(this.agentDir, "npm"); return join(this.agentDir, "npm");
@@ -1933,6 +1942,7 @@ export class DefaultPackageManager implements PackageManager {
return join(this.getTemporaryDir("npm"), "node_modules", source.name); return join(this.getTemporaryDir("npm"), "node_modules", source.name);
} }
if (scope === "project") { if (scope === "project") {
this.assertProjectTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
} }
return join(this.agentDir, "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; return undefined;
} }
if (scope === "project") { if (scope === "project") {
this.assertProjectTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME, "git"); return join(this.cwd, CONFIG_DIR_NAME, "git");
} }
return join(this.agentDir, "git"); return join(this.agentDir, "git");
@@ -1996,6 +2007,7 @@ export class DefaultPackageManager implements PackageManager {
private getBaseDirForScope(scope: SourceScope): string { private getBaseDirForScope(scope: SourceScope): string {
if (scope === "project") { if (scope === "project") {
this.assertProjectTrustedForScope(scope);
return join(this.cwd, CONFIG_DIR_NAME); return join(this.cwd, CONFIG_DIR_NAME);
} }
if (scope === "user") { if (scope === "user") {
@@ -2257,9 +2269,10 @@ export class DefaultPackageManager implements PackageManager {
themes: join(projectBaseDir, "themes"), themes: join(projectBaseDir, "themes"),
}; };
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd).filter( const projectTrusted = this.settingsManager.isProjectTrusted();
(dir) => resolve(dir) !== resolve(userAgentsSkillsDir), const projectAgentsSkillDirs = projectTrusted
); ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
: [];
const addResources = ( const addResources = (
resourceType: ResourceType, resourceType: ResourceType,
@@ -2275,23 +2288,25 @@ export class DefaultPackageManager implements PackageManager {
} }
}; };
// Project extensions from .pi/ if (projectTrusted) {
addResources( // Project extensions from .pi/
"extensions", addResources(
collectAutoExtensionEntries(projectDirs.extensions), "extensions",
projectMetadata, collectAutoExtensionEntries(projectDirs.extensions),
projectOverrides.extensions, projectMetadata,
projectBaseDir, projectOverrides.extensions,
); projectBaseDir,
);
// Project skills from .pi/ // Project skills from .pi/
addResources( addResources(
"skills", "skills",
collectAutoSkillEntries(projectDirs.skills, "pi"), collectAutoSkillEntries(projectDirs.skills, "pi"),
projectMetadata, projectMetadata,
projectOverrides.skills, projectOverrides.skills,
projectBaseDir, projectBaseDir,
); );
}
// Project skills from .agents/ (each with its own baseDir) // Project skills from .agents/ (each with its own baseDir)
for (const agentsSkillsDir of projectAgentsSkillDirs) { for (const agentsSkillsDir of projectAgentsSkillDirs) {
@@ -2309,20 +2324,22 @@ export class DefaultPackageManager implements PackageManager {
); );
} }
addResources( if (projectTrusted) {
"prompts", addResources(
collectAutoPromptEntries(projectDirs.prompts), "prompts",
projectMetadata, collectAutoPromptEntries(projectDirs.prompts),
projectOverrides.prompts, projectMetadata,
projectBaseDir, projectOverrides.prompts,
); projectBaseDir,
addResources( );
"themes", addResources(
collectAutoThemeEntries(projectDirs.themes), "themes",
projectMetadata, collectAutoThemeEntries(projectDirs.themes),
projectOverrides.themes, projectMetadata,
projectBaseDir, projectOverrides.themes,
); projectBaseDir,
);
}
// User extensions from ~/.pi/agent/ // User extensions from ~/.pi/agent/
addResources( addResources(

View File

@@ -75,6 +75,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
export function loadProjectContextFiles(options: { export function loadProjectContextFiles(options: {
cwd: string; cwd: string;
agentDir: string; agentDir: string;
projectTrusted?: boolean;
}): Array<{ path: string; content: string }> { }): Array<{ path: string; content: string }> {
const resolvedCwd = resolvePath(options.cwd); const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir); const resolvedAgentDir = resolvePath(options.agentDir);
@@ -88,27 +89,29 @@ export function loadProjectContextFiles(options: {
seenPaths.add(globalContext.path); 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; let currentDir = resolvedCwd;
const root = resolve("/"); const root = resolve("/");
while (true) { while (true) {
const contextFile = loadContextFileFromDir(currentDir); const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) { if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile); ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path); seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
} }
if (currentDir === root) break; contextFiles.push(...ancestorContextFiles);
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
} }
contextFiles.push(...ancestorContextFiles);
return contextFiles; return contextFiles;
} }
@@ -466,7 +469,13 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
const agentsFiles = { 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; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
this.agentsFiles = resolvedAgentsFiles.agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles;
@@ -852,7 +861,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private discoverSystemPromptFile(): string | undefined { private discoverSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (existsSync(projectPath)) { if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
return projectPath; return projectPath;
} }
@@ -866,7 +875,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private discoverAppendSystemPromptFile(): string | undefined { private discoverAppendSystemPromptFile(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");
if (existsSync(projectPath)) { if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
return projectPath; return projectPath;
} }

View File

@@ -159,6 +159,10 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde
export type SettingsScope = "global" | "project"; export type SettingsScope = "global" | "project";
export interface SettingsManagerCreateOptions {
projectTrusted?: boolean;
}
export interface SettingsStorage { export interface SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
} }
@@ -259,6 +263,7 @@ export class SettingsManager {
private globalSettings: Settings; private globalSettings: Settings;
private projectSettings: Settings; private projectSettings: Settings;
private settings: Settings; private settings: Settings;
private projectTrusted: boolean;
private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications
private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session
@@ -275,10 +280,12 @@ export class SettingsManager {
globalLoadError: Error | null = null, globalLoadError: Error | null = null,
projectLoadError: Error | null = null, projectLoadError: Error | null = null,
initialErrors: SettingsError[] = [], initialErrors: SettingsError[] = [],
projectTrusted = true,
) { ) {
this.storage = storage; this.storage = storage;
this.globalSettings = initialGlobal; this.globalSettings = initialGlobal;
this.projectSettings = initialProject; this.projectSettings = initialProject;
this.projectTrusted = projectTrusted;
this.globalSettingsLoadError = globalLoadError; this.globalSettingsLoadError = globalLoadError;
this.projectSettingsLoadError = projectLoadError; this.projectSettingsLoadError = projectLoadError;
this.errors = [...initialErrors]; this.errors = [...initialErrors];
@@ -286,15 +293,20 @@ export class SettingsManager {
} }
/** Create a SettingsManager that loads from files */ /** 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); const storage = new FileSettingsStorage(cwd, agentDir);
return SettingsManager.fromStorage(storage); return SettingsManager.fromStorage(storage, options);
} }
/** Create a SettingsManager from an arbitrary storage backend */ /** 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 globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project"); const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted);
const initialErrors: SettingsError[] = []; const initialErrors: SettingsError[] = [];
if (globalLoad.error) { if (globalLoad.error) {
initialErrors.push({ scope: "global", error: globalLoad.error }); initialErrors.push({ scope: "global", error: globalLoad.error });
@@ -310,6 +322,7 @@ export class SettingsManager {
globalLoad.error, globalLoad.error,
projectLoad.error, projectLoad.error,
initialErrors, initialErrors,
projectTrusted,
); );
} }
@@ -321,7 +334,11 @@ export class SettingsManager {
return SettingsManager.fromStorage(storage); 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; let content: string | undefined;
storage.withLock(scope, (current) => { storage.withLock(scope, (current) => {
content = current; content = current;
@@ -338,9 +355,10 @@ export class SettingsManager {
private static tryLoadFromStorage( private static tryLoadFromStorage(
storage: SettingsStorage, storage: SettingsStorage,
scope: SettingsScope, scope: SettingsScope,
projectTrusted = true,
): { settings: Settings; error: Error | null } { ): { settings: Settings; error: Error | null } {
try { try {
return { settings: SettingsManager.loadFromStorage(storage, scope), error: null }; return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null };
} catch (error) { } catch (error) {
return { settings: {}, error: error as Error }; return { settings: {}, error: error as Error };
} }
@@ -416,6 +434,35 @@ export class SettingsManager {
return structuredClone(this.projectSettings); 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<void> { async reload(): Promise<void> {
await this.writeQueue; await this.writeQueue;
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
@@ -432,7 +479,7 @@ export class SettingsManager {
this.modifiedProjectFields.clear(); this.modifiedProjectFields.clear();
this.modifiedProjectNestedFields.clear(); this.modifiedProjectNestedFields.clear();
const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project"); const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted);
if (!projectLoad.error) { if (!projectLoad.error) {
this.projectSettings = projectLoad.settings; this.projectSettings = projectLoad.settings;
this.projectSettingsLoadError = null; 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 { private recordError(scope: SettingsScope, error: unknown): void {
const normalizedError = error instanceof Error ? error : new Error(String(error)); const normalizedError = error instanceof Error ? error : new Error(String(error));
this.errors.push({ scope, error: normalizedError }); this.errors.push({ scope, error: normalizedError });
@@ -490,6 +543,9 @@ export class SettingsManager {
private enqueueWrite(scope: SettingsScope, task: () => void): void { private enqueueWrite(scope: SettingsScope, task: () => void): void {
this.writeQueue = this.writeQueue this.writeQueue = this.writeQueue
.then(() => { .then(() => {
if (scope === "project") {
this.assertProjectTrustedForWrite();
}
task(); task();
this.clearModifiedScope(scope); this.clearModifiedScope(scope);
}) })
@@ -554,6 +610,7 @@ export class SettingsManager {
} }
private saveProjectSettings(settings: Settings): void { private saveProjectSettings(settings: Settings): void {
this.assertProjectTrustedForWrite();
this.projectSettings = structuredClone(settings); this.projectSettings = structuredClone(settings);
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); 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<void> { async flush(): Promise<void> {
await this.writeQueue; await this.writeQueue;
} }
@@ -839,10 +904,9 @@ export class SettingsManager {
} }
setProjectPackages(packages: PackageSource[]): void { setProjectPackages(packages: PackageSource[]): void {
const projectSettings = structuredClone(this.projectSettings); this.updateProjectSettings("packages", (settings) => {
projectSettings.packages = packages; settings.packages = packages;
this.markProjectModified("packages"); });
this.saveProjectSettings(projectSettings);
} }
getExtensionPaths(): string[] { getExtensionPaths(): string[] {
@@ -856,10 +920,9 @@ export class SettingsManager {
} }
setProjectExtensionPaths(paths: string[]): void { setProjectExtensionPaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings); this.updateProjectSettings("extensions", (settings) => {
projectSettings.extensions = paths; settings.extensions = paths;
this.markProjectModified("extensions"); });
this.saveProjectSettings(projectSettings);
} }
getSkillPaths(): string[] { getSkillPaths(): string[] {
@@ -873,10 +936,9 @@ export class SettingsManager {
} }
setProjectSkillPaths(paths: string[]): void { setProjectSkillPaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings); this.updateProjectSettings("skills", (settings) => {
projectSettings.skills = paths; settings.skills = paths;
this.markProjectModified("skills"); });
this.saveProjectSettings(projectSettings);
} }
getPromptTemplatePaths(): string[] { getPromptTemplatePaths(): string[] {
@@ -890,10 +952,9 @@ export class SettingsManager {
} }
setProjectPromptTemplatePaths(paths: string[]): void { setProjectPromptTemplatePaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings); this.updateProjectSettings("prompts", (settings) => {
projectSettings.prompts = paths; settings.prompts = paths;
this.markProjectModified("prompts"); });
this.saveProjectSettings(projectSettings);
} }
getThemePaths(): string[] { getThemePaths(): string[] {
@@ -907,10 +968,9 @@ export class SettingsManager {
} }
setProjectThemePaths(paths: string[]): void { setProjectThemePaths(paths: string[]): void {
const projectSettings = structuredClone(this.projectSettings); this.updateProjectSettings("themes", (settings) => {
projectSettings.themes = paths; settings.themes = paths;
this.markProjectModified("themes"); });
this.saveProjectSettings(projectSettings);
} }
getEnableSkillCommands(): boolean { getEnableSkillCommands(): boolean {

View File

@@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "fork", description: "Create a new fork from a previous user message" }, { name: "fork", description: "Create a new fork from a previous user message" },
{ name: "clone", description: "Duplicate the current session at the current position" }, { name: "clone", description: "Duplicate the current session at the current position" },
{ name: "tree", description: "Navigate session tree (switch branches)" }, { 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: "login", description: "Configure provider authentication" },
{ name: "logout", description: "Remove provider authentication" }, { name: "logout", description: "Remove provider authentication" },
{ name: "new", description: "Start a new session" }, { name: "new", description: "Start a new session" },

View File

@@ -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<string, boolean | null | undefined>;
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<T>(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);
});
}
}

View File

@@ -219,6 +219,7 @@ export {
type PackageSource, type PackageSource,
type RetrySettings, type RetrySettings,
SettingsManager, SettingsManager,
type SettingsManagerCreateOptions,
} from "./core/settings-manager.ts"; } from "./core/settings-manager.ts";
// Skills // Skills
export { export {
@@ -281,6 +282,7 @@ export {
type WriteToolOptions, type WriteToolOptions,
withFileMutationQueue, withFileMutationQueue,
} from "./core/tools/index.ts"; } from "./core/tools/index.ts";
export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts";
// Main entry point // Main entry point
export { type MainOptions, main } from "./main.ts"; export { type MainOptions, main } from "./main.ts";
// Run modes for programmatic SDK usage // Run modes for programmatic SDK usage

View File

@@ -40,6 +40,7 @@ import {
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts"; import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.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)); return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
} }
async function promptForMissingSessionCwd( async function showStartupSelector<T>(
issue: SessionCwdIssue,
settingsManager: SettingsManager, settingsManager: SettingsManager,
): Promise<string | undefined> { title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
initTheme(settingsManager.getTheme()); initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create()); setKeybindings(KeybindingsManager.create());
@@ -448,7 +450,7 @@ async function promptForMissingSessionCwd(
ui.setClearOnShrink(settingsManager.getClearOnShrink()); ui.setClearOnShrink(settingsManager.getClearOnShrink());
let settled = false; let settled = false;
const finish = (result: string | undefined) => { const finish = (result: T | undefined) => {
if (settled) { if (settled) {
return; return;
} }
@@ -458,9 +460,9 @@ async function promptForMissingSessionCwd(
}; };
const selector = new ExtensionSelectorComponent( const selector = new ExtensionSelectorComponent(
formatMissingSessionCwdPrompt(issue), title,
["Continue", "Cancel"], options.map((option) => option.label),
(option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), (option) => finish(options.find((entry) => entry.label === option)?.value),
() => finish(undefined), () => finish(undefined),
{ tui: ui }, { tui: ui },
); );
@@ -470,6 +472,69 @@ async function promptForMissingSessionCwd(
}); });
} }
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
settingsManager: SettingsManager,
): Promise<string | undefined> {
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<ProjectTrustPromptResult | undefined> {
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<boolean> {
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 { export interface MainOptions {
extensionFactories?: ExtensionFactory[]; extensionFactories?: ExtensionFactory[];
} }
@@ -581,6 +646,16 @@ export async function main(args: string[], options?: MainOptions) {
} }
time("createSessionManager"); 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 resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);
const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
@@ -592,10 +667,16 @@ export async function main(args: string[], options?: MainOptions) {
sessionManager, sessionManager,
sessionStartEvent, 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({ const services = await createAgentSessionServices({
cwd, cwd,
agentDir, agentDir,
authStorage, authStorage,
settingsManager: runtimeSettingsManager,
extensionFlagValues: parsed.unknownFlags, extensionFlagValues: parsed.unknownFlags,
resourceLoaderOptions: { resourceLoaderOptions: {
additionalExtensionPaths: resolvedExtensionPaths, additionalExtensionPaths: resolvedExtensionPaths,

View File

@@ -27,6 +27,7 @@ export { ThemeSelectorComponent } from "./theme-selector.ts";
export { ThinkingSelectorComponent } from "./thinking-selector.ts"; export { ThinkingSelectorComponent } from "./thinking-selector.ts";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
export { TreeSelectorComponent } from "./tree-selector.ts"; export { TreeSelectorComponent } from "./tree-selector.ts";
export { TrustSelectorComponent } from "./trust-selector.ts";
export { UserMessageComponent } from "./user-message.ts"; export { UserMessageComponent } from "./user-message.ts";
export { UserMessageSelectorComponent } from "./user-message-selector.ts"; export { UserMessageSelectorComponent } from "./user-message-selector.ts";
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts";

View File

@@ -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();
}
}
}

View File

@@ -85,6 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts"; import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.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 { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts"; import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.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 { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { import {
@@ -2564,6 +2566,11 @@ export class InteractiveMode {
this.editor.setText(""); this.editor.setText("");
return; return;
} }
if (text === "/trust") {
this.showTrustSelector();
this.editor.setText("");
return;
}
if (text === "/login") { if (text === "/login") {
this.showOAuthSelector("login"); this.showOAuthSelector("login");
this.editor.setText(""); this.editor.setText("");
@@ -3226,6 +3233,7 @@ export class InteractiveMode {
updateFooter: true, updateFooter: true,
populateHistory: true, populateHistory: true,
}); });
this.renderProjectTrustWarningIfNeeded();
// Show compaction info if session was compacted // Show compaction info if session was compacted
const allEntries = this.sessionManager.getEntries(); 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<string> { async getUserInput(): Promise<string> {
const queuedInput = this.pendingUserInputs.shift(); const queuedInput = this.pendingUserInputs.shift();
if (queuedInput !== undefined) { 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 { private showModelSelector(initialSearchInput?: string): void {
this.showSelector((done) => { this.showSelector((done) => {
const selector = new ModelSelectorComponent( const selector = new ModelSelectorComponent(

View File

@@ -14,6 +14,7 @@ import {
} from "./config.ts"; } from "./config.ts";
import { DefaultPackageManager } from "./core/package-manager.ts"; import { DefaultPackageManager } from "./core/package-manager.ts";
import { SettingsManager } from "./core/settings-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 { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import { import {
@@ -48,6 +49,7 @@ interface PackageCommandOptions {
updateTarget?: UpdateTarget; updateTarget?: UpdateTarget;
local: boolean; local: boolean;
force: boolean; force: boolean;
projectTrustOverride?: boolean;
help: boolean; help: boolean;
invalidOption?: string; invalidOption?: string;
invalidArgument?: string; invalidArgument?: string;
@@ -68,13 +70,13 @@ function reportSettingsErrors(settingsManager: SettingsManager, context: string)
function getPackageCommandUsage(command: PackageCommand): string { function getPackageCommandUsage(command: PackageCommand): string {
switch (command) { switch (command) {
case "install": case "install":
return `${APP_NAME} install <source> [-l]`; return `${APP_NAME} install <source> [-l] [--approve|--no-approve]`;
case "remove": case "remove":
return `${APP_NAME} remove <source> [-l]`; return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
case "update": case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`; return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--approve|--no-approve] [--force]`;
case "list": 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. Install a package and add it to settings.
Options: 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: Examples:
${APP_NAME} install npm:@foo/bar ${APP_NAME} install npm:@foo/bar
@@ -107,7 +111,9 @@ Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l] Alias: ${APP_NAME} uninstall <source> [-l]
Options: 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: Examples:
${APP_NAME} remove npm:@foo/bar ${APP_NAME} remove npm:@foo/bar
@@ -125,6 +131,8 @@ Options:
--self Update pi only --self Update pi only
--extensions Update installed packages only --extensions Update installed packages only
--extension <source> Update one package only --extension <source> 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 --force Reinstall pi even if the current version is latest
Short forms: Short forms:
@@ -139,6 +147,10 @@ Short forms:
${getPackageCommandUsage("list")} ${getPackageCommandUsage("list")}
List installed packages from user and project settings. 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; return;
} }
@@ -158,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
let local = false; let local = false;
let force = false; let force = false;
let projectTrustOverride: boolean | undefined;
let help = false; let help = false;
let invalidOption: string | undefined; let invalidOption: string | undefined;
let invalidArgument: string | undefined; let invalidArgument: string | undefined;
@@ -202,6 +215,16 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue; continue;
} }
if (arg === "--approve" || arg === "-a") {
projectTrustOverride = true;
continue;
}
if (arg === "--no-approve" || arg === "-na") {
projectTrustOverride = false;
continue;
}
if (arg === "--force") { if (arg === "--force") {
if (command === "update") { if (command === "update") {
force = true; force = true;
@@ -280,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
updateTarget, updateTarget,
local, local,
force, force,
projectTrustOverride,
help, help,
invalidOption, invalidOption,
invalidArgument, invalidArgument,
@@ -389,6 +413,25 @@ function prepareWindowsNpmSelfUpdate(): void {
quarantineWindowsNativeDependencies(packageDir); 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<boolean> { export async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") { if (args[0] !== "config") {
return false; return false;
@@ -396,7 +439,8 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd(); const cwd = process.cwd();
const agentDir = getAgentDir(); 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"); reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve(); const resolvedPaths = await packageManager.resolve();
@@ -460,7 +504,14 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd(); const cwd = process.cwd();
const agentDir = getAgentDir(); 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"); reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;

View File

@@ -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", () => { describe("--verbose flag", () => {
test("parses --verbose flag", () => { test("parses --verbose flag", () => {
const result = parseArgs(["--verbose"]); const result = parseArgs(["--verbose"]);

View File

@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; 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"; import { main } from "../src/main.ts";
describe("package commands", () => { describe("package commands", () => {
@@ -85,6 +86,103 @@ describe("package commands", () => {
expect(removedSettings.packages ?? []).toHaveLength(0); 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 () => { it("shows install subcommand help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").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"); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
expect(stderr).toContain('Unknown option --unknown for "install".'); expect(stderr).toContain('Unknown option --unknown for "install".');
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l]".'); expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [--approve|--no-approve]".');
expect(process.exitCode).toBe(1); expect(process.exitCode).toBe(1);
} finally { } finally {
errorSpy.mockRestore(); errorSpy.mockRestore();

View File

@@ -329,6 +329,52 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); 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 () => { it("should discover APPEND_SYSTEM.md", async () => {
const piDir = join(cwd, ".pi"); const piDir = join(cwd, ".pi");
mkdirSync(piDir, { recursive: true }); mkdirSync(piDir, { recursive: true });

View File

@@ -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", () => { describe("project settings directory creation", () => {
it("should not create .pi folder when only reading project settings", () => { it("should not create .pi folder when only reading project settings", () => {
// Create agent dir with global settings, but NO .pi folder in project // Create agent dir with global settings, but NO .pi folder in project

View File

@@ -80,8 +80,8 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
} }
describe("stdout cleanliness in non-interactive modes", () => { describe("stdout cleanliness in non-interactive modes", () => {
it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => { it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help"]); const result = await runCli(["--mode", "json", "--help", "--approve"]);
expect(result.code).toBe(0); expect(result.code).toBe(0);
expect(result.stdout).toBe(""); expect(result.stdout).toBe("");
@@ -90,13 +90,23 @@ describe("stdout cleanliness in non-interactive modes", () => {
expect(result.stderr).toContain("Usage:"); 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"]); const result = await runCli(["-p", "--help"]);
expect(result.code).toBe(0); expect(result.code).toBe(0);
expect(result.stdout).toBe(""); expect(result.stdout).toBe("");
expect(result.stderr).toContain("changed 1 package in 471ms"); expect(result.stderr).not.toContain("changed 1 package in 471ms");
expect(result.stderr).toContain("found 0 vulnerabilities"); expect(result.stderr).not.toContain("found 0 vulnerabilities");
expect(result.stderr).toContain("Usage:"); expect(result.stderr).toContain("Usage:");
}); });
}); });

View File

@@ -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);
});
});

View File

@@ -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);
});
});