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