mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix(coding-agent): gate all project .pi access by trust
This commit is contained in:
@@ -290,7 +290,7 @@ See [docs/settings.md](docs/settings.md) for all options.
|
||||
|
||||
### Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
|
||||
### Telemetry and update checks
|
||||
|
||||
@@ -589,7 +589,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
|
||||
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
|
||||
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Edit directly or use `/settings` for common options.
|
||||
|
||||
## Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
|
||||
## All Settings
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in tho
|
||||
|
||||
### Project Trust
|
||||
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads project config, `false` skips it, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project config for one run regardless of trust.
|
||||
Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust.
|
||||
|
||||
## Exporting and Sharing Sessions
|
||||
|
||||
@@ -224,7 +224,7 @@ pi --no-extensions -e ./my-extension.ts
|
||||
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-f`, `--force` | Force loading project `.pi` config regardless of trust |
|
||||
| `-f`, `--force` | Force loading project `.pi` regardless of trust |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ ${chalk.bold("Options:")}
|
||||
--export <file> Export session file to HTML and exit
|
||||
--list-models [search] List available models (with optional fuzzy search)
|
||||
--verbose Force verbose startup (overrides quietStartup setting)
|
||||
--force, -f Force loading project .pi config
|
||||
--force, -f Force loading project .pi
|
||||
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
||||
--help, -h Show this help
|
||||
--version, -v Show version number
|
||||
|
||||
@@ -822,6 +822,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
const parsed = this.parseSource(source);
|
||||
if (parsed.type === "npm") {
|
||||
const path = this.getNpmInstallPath(parsed, scope);
|
||||
@@ -956,6 +957,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async install(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
await this.withProgress("install", source, `Installing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.installNpm(parsed, scope, false);
|
||||
@@ -984,6 +986,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
async remove(source: string, options?: { local?: boolean }): Promise<void> {
|
||||
const parsed = this.parseSource(source);
|
||||
const scope: SourceScope = options?.local ? "project" : "user";
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
await this.withProgress("remove", source, `Removing ${source}...`, async () => {
|
||||
if (parsed.type === "npm") {
|
||||
await this.uninstallNpm(parsed, scope);
|
||||
@@ -1290,6 +1293,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
|
||||
private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise<void> {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
if (parsed.type === "npm") {
|
||||
await this.installNpm(parsed, scope, scope === "temporary");
|
||||
return;
|
||||
@@ -1666,6 +1670,12 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
private assertProjectPiTrustedForScope(scope: SourceScope): void {
|
||||
if (scope === "project" && !this.settingsManager.isProjectConfigTrusted()) {
|
||||
throw new Error("Project .pi is not trusted; refusing to access project package storage");
|
||||
}
|
||||
}
|
||||
|
||||
private getNpmCommand(): { command: string; args: string[] } {
|
||||
const configuredCommand = this.settingsManager.getNpmCommand();
|
||||
if (!configuredCommand || configuredCommand.length === 0) {
|
||||
@@ -1886,6 +1896,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return this.getTemporaryDir("npm");
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm");
|
||||
}
|
||||
return join(this.agentDir, "npm");
|
||||
@@ -1926,6 +1937,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
|
||||
}
|
||||
return join(this.agentDir, "npm", "node_modules", source.name);
|
||||
@@ -1964,6 +1976,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
return undefined;
|
||||
}
|
||||
if (scope === "project") {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME, "git");
|
||||
}
|
||||
return join(this.agentDir, "git");
|
||||
@@ -1989,6 +2002,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
|
||||
private getBaseDirForScope(scope: SourceScope): string {
|
||||
if (scope === "project") {
|
||||
this.assertProjectPiTrustedForScope(scope);
|
||||
return join(this.cwd, CONFIG_DIR_NAME);
|
||||
}
|
||||
if (scope === "user") {
|
||||
@@ -2250,8 +2264,8 @@ export class DefaultPackageManager implements PackageManager {
|
||||
themes: join(projectBaseDir, "themes"),
|
||||
};
|
||||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectConfigTrusted = this.settingsManager.isProjectConfigTrusted();
|
||||
const includeProjectScopedResources = projectConfigTrusted || !existsSync(projectBaseDir);
|
||||
const projectPiTrusted = this.settingsManager.isProjectConfigTrusted();
|
||||
const includeProjectScopedResources = projectPiTrusted || !existsSync(projectBaseDir);
|
||||
const projectAgentsSkillDirs = includeProjectScopedResources
|
||||
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
|
||||
: [];
|
||||
@@ -2270,7 +2284,7 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
};
|
||||
|
||||
if (projectConfigTrusted) {
|
||||
if (projectPiTrusted) {
|
||||
// Project extensions from .pi/
|
||||
addResources(
|
||||
"extensions",
|
||||
|
||||
@@ -224,7 +224,7 @@ export class FileSettingsStorage implements SettingsStorage {
|
||||
|
||||
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
|
||||
if (scope === "project" && !this.projectConfigTrusted) {
|
||||
throw new Error("Project config is not trusted; refusing to access project settings");
|
||||
throw new Error("Project .pi is not trusted; refusing to access project settings");
|
||||
}
|
||||
|
||||
const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
|
||||
@@ -272,7 +272,7 @@ export class InMemorySettingsStorage implements SettingsStorage {
|
||||
|
||||
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
|
||||
if (scope === "project" && !this.projectConfigTrusted) {
|
||||
throw new Error("Project config is not trusted; refusing to access project settings");
|
||||
throw new Error("Project .pi is not trusted; refusing to access project settings");
|
||||
}
|
||||
|
||||
const current = scope === "global" ? this.global : this.project;
|
||||
@@ -535,7 +535,7 @@ export class SettingsManager {
|
||||
|
||||
private assertProjectConfigTrustedForWrite(): void {
|
||||
if (!this.projectConfigTrusted) {
|
||||
throw new Error("Project config is not trusted; refusing to write project settings");
|
||||
throw new Error("Project .pi is not trusted; refusing to write project settings");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function withTrustFileLock<T>(path: string, fn: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasProjectConfig(cwd: string): boolean {
|
||||
export function hasProjectPiDirectory(cwd: string): boolean {
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
return existsSync(join(resolvedCwd, CONFIG_DIR_NAME));
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { hasProjectPiDirectory, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
|
||||
@@ -443,7 +443,7 @@ async function showStartupSelector<T>(
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
// Startup prompts run before resource loading. Themes from packages, --themes,
|
||||
// and project config are unavailable here; built-ins and user theme files work.
|
||||
// and project .pi resources are unavailable here; built-ins and user theme files work.
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
|
||||
@@ -487,7 +487,7 @@ async function promptForMissingSessionCwd(
|
||||
async function promptForProjectTrust(cwd: string, settingsManager: SettingsManager): Promise<boolean | undefined> {
|
||||
return showStartupSelector(
|
||||
settingsManager,
|
||||
`Trust project configuration?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
|
||||
`Trust project .pi directory?\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
|
||||
[
|
||||
{ label: "Trust", value: true },
|
||||
{ label: "Don't trust", value: false },
|
||||
@@ -495,7 +495,7 @@ async function promptForProjectTrust(cwd: string, settingsManager: SettingsManag
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProjectConfigTrusted(options: {
|
||||
async function resolveProjectPiTrusted(options: {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
force: boolean;
|
||||
@@ -505,7 +505,7 @@ async function resolveProjectConfigTrusted(options: {
|
||||
if (options.force) {
|
||||
return true;
|
||||
}
|
||||
if (!hasProjectConfig(options.cwd)) {
|
||||
if (!hasProjectPiDirectory(options.cwd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -597,23 +597,22 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
const agentDir = getAgentDir();
|
||||
const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false });
|
||||
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
|
||||
const forceProjectConfigTrusted = parsed.force === true;
|
||||
const forceProjectPiTrust = parsed.force === true;
|
||||
const trustStore = new ProjectTrustStore(agentDir);
|
||||
const startupProjectConfigTrusted = await resolveProjectConfigTrusted({
|
||||
const startupProjectPiTrusted = await resolveProjectPiTrusted({
|
||||
cwd,
|
||||
trustStore,
|
||||
force: forceProjectConfigTrusted,
|
||||
force: forceProjectPiTrust,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: promptSettingsManager,
|
||||
});
|
||||
// Legacy extension migrations are intentional filesystem housekeeping and run
|
||||
// regardless of trust. Trust gates loading/executing project config, not moving
|
||||
// old Pi config directories to the current layout.
|
||||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
|
||||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd, {
|
||||
projectPiTrusted: startupProjectPiTrusted,
|
||||
});
|
||||
time("runMigrations");
|
||||
|
||||
const startupSettingsManager = SettingsManager.create(cwd, agentDir, {
|
||||
projectConfigTrusted: startupProjectConfigTrusted,
|
||||
projectConfigTrusted: startupProjectPiTrusted,
|
||||
});
|
||||
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
|
||||
|
||||
@@ -653,10 +652,10 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
|
||||
const initialRuntimeCwd = sessionManager.getCwd();
|
||||
if (initialRuntimeCwd !== cwd) {
|
||||
await resolveProjectConfigTrusted({
|
||||
await resolveProjectPiTrusted({
|
||||
cwd: initialRuntimeCwd,
|
||||
trustStore,
|
||||
force: forceProjectConfigTrusted,
|
||||
force: forceProjectPiTrust,
|
||||
appMode: trustPromptMode,
|
||||
settingsManagerForPrompt: promptSettingsManager,
|
||||
});
|
||||
@@ -672,8 +671,8 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
sessionManager,
|
||||
sessionStartEvent,
|
||||
}) => {
|
||||
const projectConfigTrusted = forceProjectConfigTrusted || (hasProjectConfig(cwd) && trustStore.get(cwd) === true);
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
|
||||
const projectPiTrusted = forceProjectPiTrust || (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true);
|
||||
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
@@ -833,7 +832,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
initialImages,
|
||||
initialMessages: parsed.messages,
|
||||
verbose: parsed.verbose,
|
||||
forceProjectConfigTrust: forceProjectConfigTrusted,
|
||||
forceProjectPiTrust,
|
||||
});
|
||||
if (startupBenchmark) {
|
||||
await interactiveMode.init();
|
||||
|
||||
@@ -384,22 +384,23 @@ function checkDeprecatedExtensionDirs(baseDir: string, label: string): string[]
|
||||
|
||||
/**
|
||||
* Run extension system migrations (commands→prompts) and collect warnings about deprecated directories.
|
||||
* This intentionally runs even for untrusted projects: it performs legacy Pi config
|
||||
* housekeeping only and does not load or execute project extensions.
|
||||
* Project .pi migrations only run after the project .pi directory is trusted.
|
||||
*/
|
||||
function migrateExtensionSystem(cwd: string): string[] {
|
||||
function migrateExtensionSystem(cwd: string, options: { projectPiTrusted: boolean }): string[] {
|
||||
const agentDir = getAgentDir();
|
||||
const projectDir = join(cwd, CONFIG_DIR_NAME);
|
||||
|
||||
// Migrate commands/ to prompts/
|
||||
migrateCommandsToPrompts(agentDir, "Global");
|
||||
migrateCommandsToPrompts(projectDir, "Project");
|
||||
if (options.projectPiTrusted) {
|
||||
migrateCommandsToPrompts(projectDir, "Project");
|
||||
}
|
||||
|
||||
// Check for deprecated directories
|
||||
const warnings = [
|
||||
...checkDeprecatedExtensionDirs(agentDir, "Global"),
|
||||
...checkDeprecatedExtensionDirs(projectDir, "Project"),
|
||||
];
|
||||
const warnings = [...checkDeprecatedExtensionDirs(agentDir, "Global")];
|
||||
if (options.projectPiTrusted) {
|
||||
warnings.push(...checkDeprecatedExtensionDirs(projectDir, "Project"));
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
@@ -435,7 +436,10 @@ export async function showDeprecationWarnings(warnings: string[]): Promise<void>
|
||||
*
|
||||
* @returns Object with migration results and deprecation warnings
|
||||
*/
|
||||
export function runMigrations(cwd: string): {
|
||||
export function runMigrations(
|
||||
cwd: string,
|
||||
options: { projectPiTrusted?: boolean } = {},
|
||||
): {
|
||||
migratedAuthProviders: string[];
|
||||
deprecationWarnings: string[];
|
||||
} {
|
||||
@@ -444,6 +448,6 @@ export function runMigrations(cwd: string): {
|
||||
migrateSessionsFromAgentRoot();
|
||||
migrateToolsToBin();
|
||||
migrateKeybindingsConfigFile();
|
||||
const deprecationWarnings = migrateExtensionSystem(cwd);
|
||||
const deprecationWarnings = migrateExtensionSystem(cwd, { projectPiTrusted: options.projectPiTrusted ?? true });
|
||||
return { migratedAuthProviders, deprecationWarnings };
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasProjectConfig, type ProjectTrustDecision, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { hasProjectPiDirectory, type ProjectTrustDecision, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
@@ -256,8 +256,8 @@ export interface InteractiveModeOptions {
|
||||
initialMessages?: string[];
|
||||
/** Force verbose startup (overrides quietStartup setting) */
|
||||
verbose?: boolean;
|
||||
/** Force project config trust for this process */
|
||||
forceProjectConfigTrust?: boolean;
|
||||
/** Force project .pi trust for this process */
|
||||
forceProjectPiTrust?: boolean;
|
||||
}
|
||||
|
||||
export class InteractiveMode {
|
||||
@@ -684,9 +684,9 @@ export class InteractiveMode {
|
||||
this.headerContainer.addChild(this.builtInHeader);
|
||||
}
|
||||
|
||||
if (!this.settingsManager.isProjectConfigTrusted() && hasProjectConfig(this.sessionManager.getCwd())) {
|
||||
if (!this.settingsManager.isProjectConfigTrusted() && hasProjectPiDirectory(this.sessionManager.getCwd())) {
|
||||
this.chatContainer.addChild(
|
||||
new Text(theme.fg("warning", "This project is not trusted. Change with /trust"), 1, 0),
|
||||
new Text(theme.fg("warning", "This project's .pi directory is not trusted. Change with /trust"), 1, 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4952,7 +4952,7 @@ export class InteractiveMode {
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
const current = this.formatTrustDecision(trustStore.get(cwd));
|
||||
const choice = await this.showExtensionSelector(
|
||||
`Trust project configuration?\nCurrent setting: ${current}\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
|
||||
`Trust project .pi directory?\nCurrent setting: ${current}\nLoad .pi from ${cwd}?\nWarning: Project extensions can execute code.`,
|
||||
["Trust", "Don't trust", "Reset"],
|
||||
);
|
||||
if (choice === "Trust") {
|
||||
@@ -4999,8 +4999,8 @@ export class InteractiveMode {
|
||||
const trustStore = new ProjectTrustStore(getAgentDir());
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
trustStore.set(cwd, decision);
|
||||
const projectConfigTrusted = this.options.forceProjectConfigTrust === true || decision === true;
|
||||
this.settingsManager.setProjectConfigTrusted(projectConfigTrusted);
|
||||
const projectPiTrusted = this.options.forceProjectPiTrust === true || decision === true;
|
||||
this.settingsManager.setProjectConfigTrusted(projectPiTrusted);
|
||||
await this.handleReloadCommand();
|
||||
this.showStatus(`Project trust: ${this.formatTrustDecision(decision)}`);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./config.ts";
|
||||
import { DefaultPackageManager } from "./core/package-manager.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { hasProjectConfig, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { hasProjectPiDirectory, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { spawnProcess } from "./utils/child-process.ts";
|
||||
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
|
||||
import {
|
||||
@@ -89,7 +89,7 @@ Install a package and add it to settings.
|
||||
|
||||
Options:
|
||||
-l, --local Install project-locally (.pi/settings.json)
|
||||
-f, --force Trust project config for this command
|
||||
-f, --force Trust project .pi for this command
|
||||
|
||||
Examples:
|
||||
${APP_NAME} install npm:@foo/bar
|
||||
@@ -110,7 +110,7 @@ Alias: ${APP_NAME} uninstall <source> [-l]
|
||||
|
||||
Options:
|
||||
-l, --local Remove from project settings (.pi/settings.json)
|
||||
-f, --force Trust project config for this command
|
||||
-f, --force Trust project .pi for this command
|
||||
|
||||
Examples:
|
||||
${APP_NAME} remove npm:@foo/bar
|
||||
@@ -144,7 +144,7 @@ Short forms:
|
||||
List installed packages from user and project settings.
|
||||
|
||||
Options:
|
||||
-f, --force Trust project config for this command
|
||||
-f, --force Trust project .pi for this command
|
||||
`);
|
||||
return;
|
||||
}
|
||||
@@ -398,13 +398,13 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const projectConfigExists = hasProjectConfig(cwd);
|
||||
const projectConfigTrusted =
|
||||
!projectConfigExists ||
|
||||
const projectPiExists = hasProjectPiDirectory(cwd);
|
||||
const projectPiTrusted =
|
||||
!projectPiExists ||
|
||||
args.includes("--force") ||
|
||||
args.includes("-f") ||
|
||||
new ProjectTrustStore(agentDir).get(cwd) === true;
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
|
||||
reportSettingsErrors(settingsManager, "config command");
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
@@ -468,21 +468,21 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
|
||||
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const projectConfigExists = hasProjectConfig(cwd);
|
||||
const projectPiExists = hasProjectPiDirectory(cwd);
|
||||
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
|
||||
const commandForcesProjectConfigTrust =
|
||||
const commandForcesProjectPiTrust =
|
||||
options.force && (options.command === "install" || options.command === "remove" || options.command === "list");
|
||||
const projectConfigTrusted =
|
||||
commandForcesProjectConfigTrust ||
|
||||
(projectConfigExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
|
||||
(writesProjectPackageConfig && !projectConfigExists);
|
||||
if (!projectConfigTrusted && projectConfigExists && writesProjectPackageConfig) {
|
||||
console.error(chalk.red("Project config is not trusted. Use --force to modify local package config."));
|
||||
const projectPiTrusted =
|
||||
commandForcesProjectPiTrust ||
|
||||
(projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) ||
|
||||
(writesProjectPackageConfig && !projectPiExists);
|
||||
if (!projectPiTrusted && projectPiExists && writesProjectPackageConfig) {
|
||||
console.error(chalk.red("Project .pi is not trusted. Use --force to modify local package config."));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted });
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted });
|
||||
reportSettingsErrors(settingsManager, "package command");
|
||||
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||
import { runMigrations } from "../src/migrations.ts";
|
||||
|
||||
describe("project .pi migration trust", () => {
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
let projectDir: string;
|
||||
let previousAgentDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-migrations-trust-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
agentDir = join(tempDir, "agent");
|
||||
projectDir = join(tempDir, "project");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
previousAgentDir = process.env[ENV_AGENT_DIR];
|
||||
process.env[ENV_AGENT_DIR] = agentDir;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env[ENV_AGENT_DIR];
|
||||
} else {
|
||||
process.env[ENV_AGENT_DIR] = previousAgentDir;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createLegacyProjectPi(): void {
|
||||
const projectPiDir = join(projectDir, ".pi");
|
||||
mkdirSync(join(projectPiDir, "commands"), { recursive: true });
|
||||
mkdirSync(join(projectPiDir, "hooks"), { recursive: true });
|
||||
mkdirSync(join(projectPiDir, "tools"), { recursive: true });
|
||||
writeFileSync(join(projectPiDir, "commands", "project.md"), "project prompt");
|
||||
writeFileSync(join(projectPiDir, "tools", "custom-tool"), "custom tool");
|
||||
}
|
||||
|
||||
it("does not migrate or warn for project .pi paths when untrusted", () => {
|
||||
mkdirSync(join(agentDir, "commands"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "commands", "global.md"), "global prompt");
|
||||
createLegacyProjectPi();
|
||||
|
||||
const result = runMigrations(projectDir, { projectPiTrusted: false });
|
||||
|
||||
expect(existsSync(join(agentDir, "prompts", "global.md"))).toBe(true);
|
||||
expect(existsSync(join(projectDir, ".pi", "commands", "project.md"))).toBe(true);
|
||||
expect(existsSync(join(projectDir, ".pi", "prompts"))).toBe(false);
|
||||
expect(result.deprecationWarnings.some((warning) => warning.includes("Project"))).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates and warns for project .pi migration paths when trusted", () => {
|
||||
createLegacyProjectPi();
|
||||
|
||||
const result = runMigrations(projectDir, { projectPiTrusted: true });
|
||||
|
||||
expect(existsSync(join(projectDir, ".pi", "commands"))).toBe(false);
|
||||
expect(existsSync(join(projectDir, ".pi", "prompts", "project.md"))).toBe(true);
|
||||
expect(result.deprecationWarnings).toContain(
|
||||
"Project hooks/ directory found. Hooks have been renamed to extensions.",
|
||||
);
|
||||
expect(result.deprecationWarnings).toContain(
|
||||
"Project tools/ directory contains custom tools. Custom tools have been merged into extensions.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks local package changes when project config is untrusted", async () => {
|
||||
it("blocks local package changes when project .pi is untrusted", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -147,14 +147,14 @@ describe("package commands", () => {
|
||||
await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined();
|
||||
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain("Project config is not trusted. Use --force to modify local package config.");
|
||||
expect(stderr).toContain("Project .pi is not trusted. Use --force to modify local package config.");
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows local package install to initialize fresh project config", async () => {
|
||||
it("allows local package install to initialize fresh project .pi", async () => {
|
||||
await main(["install", "-l", packageDir]);
|
||||
|
||||
const settingsPath = join(projectDir, ".pi", "settings.json");
|
||||
|
||||
@@ -329,7 +329,7 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should skip .pi SYSTEM.md when project config is not trusted", async () => {
|
||||
it("should skip .pi SYSTEM.md when project .pi is not trusted", async () => {
|
||||
const piDir = join(cwd, ".pi");
|
||||
mkdirSync(piDir, { recursive: true });
|
||||
writeFileSync(join(piDir, "SYSTEM.md"), "Project system prompt.");
|
||||
@@ -342,10 +342,31 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("Global system prompt.");
|
||||
});
|
||||
|
||||
it("should skip .pi extensions when project config is not trusted", async () => {
|
||||
const extensionsDir = join(cwd, ".pi", "extensions");
|
||||
it("should skip .pi resources when project .pi 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(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, { projectConfigTrusted: false });
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||
@@ -353,6 +374,9 @@ Content`,
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -258,8 +258,8 @@ describe("SettingsManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("project config trust", () => {
|
||||
it("should skip project settings when project config is not trusted", () => {
|
||||
describe("project .pi trust", () => {
|
||||
it("should skip project settings when project .pi is not trusted", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "global" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ theme: "project" }));
|
||||
|
||||
@@ -269,13 +269,13 @@ describe("SettingsManager", () => {
|
||||
expect(manager.getProjectSettings()).toEqual({});
|
||||
});
|
||||
|
||||
it("should fail project settings writes when project config is not trusted", async () => {
|
||||
it("should fail project settings writes when project .pi is not trusted", async () => {
|
||||
const projectSettingsPath = join(projectDir, ".pi", "settings.json");
|
||||
writeFileSync(projectSettingsPath, JSON.stringify({ packages: ["npm:existing"] }));
|
||||
const manager = SettingsManager.create(projectDir, agentDir, { projectConfigTrusted: false });
|
||||
|
||||
expect(() => manager.setProjectPackages(["npm:new"])).toThrow(
|
||||
"Project config is not trusted; refusing to write project settings",
|
||||
"Project .pi is not trusted; refusing to write project settings",
|
||||
);
|
||||
await manager.flush();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasProjectConfig, ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
import { hasProjectPiDirectory, ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
|
||||
describe("ProjectTrustStore", () => {
|
||||
let tempDir: string;
|
||||
@@ -67,10 +67,10 @@ describe("ProjectTrustStore", () => {
|
||||
expect(() => store.get(cwd)).toThrow(/Failed to read trust store/);
|
||||
});
|
||||
|
||||
it("detects .pi project config directories", () => {
|
||||
expect(hasProjectConfig(cwd)).toBe(false);
|
||||
it("detects project .pi directories", () => {
|
||||
expect(hasProjectPiDirectory(cwd)).toBe(false);
|
||||
|
||||
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||
expect(hasProjectConfig(cwd)).toBe(true);
|
||||
expect(hasProjectPiDirectory(cwd)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user