feat(coding-agent): add project trust gating
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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