feat(coding-agent): add project trust gating
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -186,6 +186,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|
||||
| `/name <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 <text>` | Replace default prompt (context files and skills still appended) |
|
||||
| `--append-system-prompt <text>` | 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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|----------|-------|
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 <path>` (repeatable)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <path>` (repeatable)
|
||||
|
||||
@@ -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 <text>` | Replace default prompt; context files and skills are still appended |
|
||||
| `--append-system-prompt <text>` | 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 |
|
||||
|
||||
|
||||
@@ -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 <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} uninstall <source> [-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} <command> --help Show help for install/remove/uninstall/update/list
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
@@ -266,6 +273,8 @@ ${chalk.bold("Options:")}
|
||||
--export <file> 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
|
||||
|
||||
@@ -963,6 +963,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<keyof Settings>(); // Track global fields modified during session
|
||||
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
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
|
||||
{ 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" },
|
||||
|
||||
153
packages/coding-agent/src/core/trust-manager.ts
Normal file
153
packages/coding-agent/src/core/trust-manager.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
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<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 {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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(
|
||||
|
||||
@@ -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 <source> [-l]`;
|
||||
return `${APP_NAME} install <source> [-l] [--approve|--no-approve]`;
|
||||
case "remove":
|
||||
return `${APP_NAME} remove <source> [-l]`;
|
||||
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
|
||||
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":
|
||||
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 <source> [-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 <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
|
||||
|
||||
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<boolean> {
|
||||
if (args[0] !== "config") {
|
||||
return false;
|
||||
@@ -396,7 +439,8 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
|
||||
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<boolean> {
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 <source> [-l]".');
|
||||
expect(stderr).toContain('Use "pi --help" or "pi install <source> [-l] [--approve|--no-approve]".');
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
60
packages/coding-agent/test/trust-manager.test.ts
Normal file
60
packages/coding-agent/test/trust-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
packages/coding-agent/test/trust-selector.test.ts
Normal file
48
packages/coding-agent/test/trust-selector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user