mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix(coding-agent): avoid project trust prompt for update (#5674)
This commit is contained in:
committed by
GitHub
Unverified
parent
a7cdc679e7
commit
b4bff7f0d0
@@ -8,7 +8,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed project trust detection to ignore global `~/.pi/agent` state when running from `$HOME`, and made `pi update` use only saved or explicit project trust without prompting ([#5619](https://github.com/earendil-works/pi/issues/5619)).
|
||||
- Fixed inherited user-message transcript rendering so standalone `+` messages no longer render as `-` ([#5657](https://github.com/earendil-works/pi/issues/5657)).
|
||||
>>>>>>> main
|
||||
- Fixed `--model` resolution for authenticated custom model IDs whose slash prefix matches an unauthenticated built-in provider ([#5643](https://github.com/earendil-works/pi/issues/5643)).
|
||||
- Fixed `/fork` to keep session parent chains connected when the forked path contains labels ([#5669](https://github.com/earendil-works/pi/issues/5669)).
|
||||
- Fixed `/share` and `/export` HTML exports to use the active fallback theme when the configured custom theme no longer exists ([#5596](https://github.com/earendil-works/pi/issues/5596)).
|
||||
|
||||
@@ -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 extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to 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 settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
|
||||
|
||||
Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings 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 an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
|
||||
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
|
||||
|
||||
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
|
||||
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.
|
||||
|
||||
@@ -527,7 +527,7 @@ pi list # List installed packages
|
||||
pi config # Enable/disable package resources
|
||||
```
|
||||
|
||||
`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command.
|
||||
`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
|
||||
|
||||
### Modes
|
||||
|
||||
|
||||
@@ -6,14 +6,18 @@ Pi is a local coding agent. It runs with the permissions of the user account tha
|
||||
|
||||
Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory.
|
||||
|
||||
Pi considers a project to have trust inputs when it finds any of these from the current working directory:
|
||||
Pi considers a project to have resources that require trust when it finds any of these from the current working directory:
|
||||
|
||||
- `.pi/` in the current directory
|
||||
- `.agents/skills` in the current directory or an ancestor directory
|
||||
- `.pi/settings.json`
|
||||
- `.pi/extensions`, `.pi/skills`, `.pi/prompts`, or `.pi/themes`
|
||||
- `.pi/SYSTEM.md` or `.pi/APPEND_SYSTEM.md`
|
||||
- project `.agents/skills` in the current directory or an ancestor directory
|
||||
|
||||
When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default.
|
||||
A bare `.pi` directory does not count as a project resource that requires trust.
|
||||
|
||||
Trusting a project allows pi to load trust-gated project inputs, including:
|
||||
When an interactive session starts in a project with resources that require trust and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default.
|
||||
|
||||
Trusting a project allows pi to load project resources that require trust, including:
|
||||
|
||||
- `.pi/settings.json`
|
||||
- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files
|
||||
|
||||
@@ -11,13 +11,13 @@ Edit directly or use `/settings` for common options.
|
||||
|
||||
## Project Trust
|
||||
|
||||
On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to 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 settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to 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 an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
|
||||
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
|
||||
|
||||
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -112,15 +112,15 @@ 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 extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to 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 settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions.
|
||||
|
||||
Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings 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 an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run.
|
||||
|
||||
If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`.
|
||||
|
||||
`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
`pi config` and package commands use the same project trust flow, except `pi update` never prompts. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them.
|
||||
|
||||
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.
|
||||
|
||||
@@ -153,7 +153,7 @@ pi list # List installed packages
|
||||
pi config # Enable/disable package resources
|
||||
```
|
||||
|
||||
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command.
|
||||
These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. `pi update` never prompts for project trust.
|
||||
|
||||
See [Pi Packages](packages.md) for package sources and security notes.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/typ
|
||||
import type { DefaultProjectTrust } from "./settings-manager.ts";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
hasProjectTrustInputs,
|
||||
hasTrustRequiringProjectResources,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStore,
|
||||
} from "./trust-manager.ts";
|
||||
@@ -46,7 +46,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
}
|
||||
if (!hasProjectTrustInputs(options.cwd)) {
|
||||
if (!hasTrustRequiringProjectResources(options.cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { CONFIG_DIR_NAME } from "../config.ts";
|
||||
@@ -25,6 +26,16 @@ export interface ProjectTrustOption {
|
||||
|
||||
type TrustFile = Record<string, boolean | null | undefined>;
|
||||
|
||||
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
|
||||
"settings.json",
|
||||
"extensions",
|
||||
"skills",
|
||||
"prompts",
|
||||
"themes",
|
||||
"SYSTEM.md",
|
||||
"APPEND_SYSTEM.md",
|
||||
] as const;
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
return canonicalizePath(resolvePath(cwd));
|
||||
}
|
||||
@@ -45,18 +56,14 @@ function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreE
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectTrustPath(cwd: string): string {
|
||||
return normalizeCwd(cwd);
|
||||
}
|
||||
|
||||
export function getProjectTrustParentPath(cwd: string): string | undefined {
|
||||
const trustPath = getProjectTrustPath(cwd);
|
||||
const trustPath = normalizeCwd(cwd);
|
||||
const parentDir = dirname(trustPath);
|
||||
return parentDir === trustPath ? undefined : parentDir;
|
||||
}
|
||||
|
||||
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
|
||||
const trustPath = getProjectTrustPath(cwd);
|
||||
const trustPath = normalizeCwd(cwd);
|
||||
const trustOptions: ProjectTrustOption[] = [
|
||||
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
|
||||
];
|
||||
@@ -167,18 +174,26 @@ function withTrustFileLock<T>(path: string, fn: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasProjectConfigDir(cwd: string): boolean {
|
||||
return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME));
|
||||
}
|
||||
|
||||
export function hasProjectTrustInputs(cwd: string): boolean {
|
||||
/**
|
||||
* Returns true when cwd has project-local resources that must be gated by
|
||||
* project trust: trust-requiring entries under cwd/.pi, or .agents/skills in
|
||||
* cwd or one of its ancestors. Returns false when no such project resources
|
||||
* exist. The user/global ~/.agents/skills directory is always treated as a
|
||||
* trusted user resource and is ignored here, even when cwd is $HOME.
|
||||
*/
|
||||
export function hasTrustRequiringProjectResources(cwd: string): boolean {
|
||||
const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
|
||||
const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
|
||||
let currentDir = canonicalizePath(resolvePath(cwd));
|
||||
if (hasProjectConfigDir(currentDir)) {
|
||||
|
||||
const configDir = join(currentDir, CONFIG_DIR_NAME);
|
||||
if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (existsSync(join(currentDir, ".agents", "skills"))) {
|
||||
const agentsSkillsDir = join(currentDir, ".agents", "skills");
|
||||
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -289,7 +289,7 @@ export {
|
||||
withFileMutationQueue,
|
||||
} from "./core/tools/index.ts";
|
||||
export {
|
||||
hasProjectTrustInputs,
|
||||
hasTrustRequiringProjectResources,
|
||||
type ProjectTrustDecision,
|
||||
ProjectTrustStore,
|
||||
type ProjectTrustStoreEntry,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
@@ -572,7 +572,9 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
const trustStore = new ProjectTrustStore(agentDir);
|
||||
const sessionCwd = sessionManager.getCwd();
|
||||
const autoTrustOnReloadCwd =
|
||||
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
|
||||
parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd)
|
||||
? sessionCwd
|
||||
: undefined;
|
||||
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
|
||||
const projectTrustByCwd = new Map<string, boolean>();
|
||||
|
||||
@@ -591,12 +593,14 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
const isInitialRuntime = sessionStartEvent === undefined;
|
||||
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
||||
const cachedProjectTrust = projectTrustByCwd.get(cwd);
|
||||
const hasTrustInputs = hasProjectTrustInputs(cwd);
|
||||
const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);
|
||||
const shouldResolveProjectTrust =
|
||||
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
|
||||
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;
|
||||
const projectTrusted = shouldResolveProjectTrust
|
||||
? false
|
||||
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
|
||||
: (cachedProjectTrust ??
|
||||
parsed.projectTrustOverride ??
|
||||
(!hasTrustRequiringResources || trustStore.get(cwd) === true));
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
getProjectTrustPath,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStoreEntry,
|
||||
} from "../../../core/trust-manager.ts";
|
||||
@@ -19,12 +18,12 @@ export interface TrustSelectorOptions {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
|
||||
function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string {
|
||||
if (decision === null) {
|
||||
return "none";
|
||||
}
|
||||
const label = decision.decision ? "trusted" : "untrusted";
|
||||
if (decision.path !== getProjectTrustPath(cwd)) {
|
||||
if (trustPath !== undefined && decision.path !== trustPath) {
|
||||
return `${label} (inherited from ${decision.path})`;
|
||||
}
|
||||
return `${label} (${decision.path})`;
|
||||
@@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container {
|
||||
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.cwd, options.savedDecision)}`), 1, 0),
|
||||
new Text(
|
||||
theme.fg(
|
||||
"muted",
|
||||
`Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`,
|
||||
),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
|
||||
|
||||
@@ -86,7 +86,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
@@ -3271,7 +3271,7 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private renderProjectTrustWarningIfNeeded(): void {
|
||||
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
|
||||
if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4198,7 +4198,7 @@ export class InteractiveMode {
|
||||
if (this.autoTrustOnReloadCwd !== cwd) {
|
||||
return false;
|
||||
}
|
||||
if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) {
|
||||
if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DefaultPackageManager } from "./core/package-manager.ts";
|
||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { spawnProcess } from "./utils/child-process.ts";
|
||||
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
|
||||
import {
|
||||
@@ -452,13 +452,21 @@ async function createCommandSettingsManager(options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
projectTrustOverride?: boolean;
|
||||
useSavedProjectTrustOnly?: boolean;
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}): Promise<CommandSettingsResult> {
|
||||
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
|
||||
const projectTrustWarnings: string[] = [];
|
||||
const trustStore = new ProjectTrustStore(options.agentDir);
|
||||
if (options.useSavedProjectTrustOnly) {
|
||||
const savedProjectTrusted = trustStore.get(options.cwd) === true;
|
||||
settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted);
|
||||
return { settingsManager, projectTrustWarnings };
|
||||
}
|
||||
|
||||
const appMode = getCommandAppMode();
|
||||
const extensionsResult =
|
||||
options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd)
|
||||
options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd)
|
||||
? await new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
@@ -472,7 +480,7 @@ async function createCommandSettingsManager(options: {
|
||||
|
||||
const projectTrusted = await resolveProjectTrusted({
|
||||
cwd: options.cwd,
|
||||
trustStore: new ProjectTrustStore(options.agentDir),
|
||||
trustStore,
|
||||
trustOverride: options.projectTrustOverride,
|
||||
defaultProjectTrust: settingsManager.getDefaultProjectTrust(),
|
||||
extensionsResult,
|
||||
@@ -576,6 +584,7 @@ export async function handlePackageCommand(
|
||||
cwd,
|
||||
agentDir,
|
||||
projectTrustOverride: options.projectTrustOverride,
|
||||
useSavedProjectTrustOnly: options.command === "update",
|
||||
extensionFactories: runtimeOptions.extensionFactories,
|
||||
});
|
||||
reportProjectTrustWarnings(projectTrustWarnings);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -202,6 +202,69 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not prompt or ask extensions for project trust during update", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
const fakeNpmPath = join(tempDir, "fake-project-npm.cjs");
|
||||
const recordPath = join(tempDir, "project-update.json");
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, ".pi", "settings.json"),
|
||||
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
|
||||
);
|
||||
let projectTrustCalled = false;
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
main(["update", "--extensions"], {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("project_trust", () => {
|
||||
projectTrustCalled = true;
|
||||
return { trusted: "yes" };
|
||||
});
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(projectTrustCalled).toBe(false);
|
||||
expect(existsSync(recordPath)).toBe(false);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses saved project trust during update", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs");
|
||||
const recordPath = join(tempDir, "trusted-project-update.json");
|
||||
writeFileSync(
|
||||
fakeNpmPath,
|
||||
`const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectDir, ".pi", "settings.json"),
|
||||
JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }),
|
||||
);
|
||||
new ProjectTrustStore(agentDir).set(projectDir, true);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["update", "--extensions"])).resolves.toBeUndefined();
|
||||
|
||||
expect(existsSync(recordPath)).toBe(true);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets trust.json override default project trust", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
@@ -223,6 +286,7 @@ describe("package commands", () => {
|
||||
|
||||
it("blocks local package changes when project is untrusted", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), "{}");
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
|
||||
@@ -376,7 +376,7 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should skip trust-gated project resources when project is not trusted", async () => {
|
||||
it("should skip project resources that require trust when project is not trusted", async () => {
|
||||
const piDir = join(cwd, ".pi");
|
||||
const extensionsDir = join(piDir, "extensions");
|
||||
const skillDir = join(piDir, "skills", "project-skill");
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getProjectTrustPath,
|
||||
hasProjectConfigDir,
|
||||
hasProjectTrustInputs,
|
||||
ProjectTrustStore,
|
||||
} from "../src/core/trust-manager.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
|
||||
describe("ProjectTrustStore", () => {
|
||||
let tempDir: string;
|
||||
@@ -26,86 +21,47 @@ describe("ProjectTrustStore", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores decisions per cwd", () => {
|
||||
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", () => {
|
||||
it("stores decisions and inherits from parent directories", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
const parentDir = join(tempDir, "trusted-parent");
|
||||
const childDir = join(parentDir, "project");
|
||||
mkdirSync(childDir, { recursive: true });
|
||||
|
||||
expect(store.get(childDir)).toBeNull();
|
||||
store.set(parentDir, true);
|
||||
store.set(childDir, false);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
|
||||
|
||||
store.setMany([
|
||||
{ path: parentDir, decision: true },
|
||||
{ path: childDir, decision: null },
|
||||
]);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
store.set(childDir, false);
|
||||
expect(store.get(childDir)).toBe(false);
|
||||
store.set(childDir, null);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
it("detects trust-requiring project resources", () => {
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = tempDir;
|
||||
try {
|
||||
mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true });
|
||||
mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true });
|
||||
expect(hasTrustRequiringProjectResources(tempDir)).toBe(false);
|
||||
expect(hasTrustRequiringProjectResources(cwd)).toBe(false);
|
||||
|
||||
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");
|
||||
});
|
||||
writeFileSync(join(tempDir, ".pi", "settings.json"), "{}");
|
||||
expect(hasTrustRequiringProjectResources(tempDir)).toBe(true);
|
||||
rmSync(join(tempDir, ".pi", "settings.json"), { force: true });
|
||||
|
||||
it("detects project trust inputs", () => {
|
||||
expect(hasProjectConfigDir(cwd)).toBe(false);
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
writeFileSync(join(cwd, ".pi", "settings.json"), "{}");
|
||||
expect(hasTrustRequiringProjectResources(cwd)).toBe(true);
|
||||
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
expect(hasProjectConfigDir(cwd)).toBe(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(false);
|
||||
rmSync(join(cwd, "AGENTS.md"), { force: true });
|
||||
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "CLAUDE.md"), { force: true });
|
||||
|
||||
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
|
||||
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
|
||||
expect(hasTrustRequiringProjectResources(cwd)).toBe(true);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user