diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5acf40dfe..83b6fbc67 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added a `project_trust` extension event so global and CLI extensions can decide project trust during startup and runtime cwd switches. +- Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches. - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. - Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 53152ca09..601d378ec 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -346,12 +346,13 @@ pi.on("project_trust", async (event, ctx) => { // event.cwd - current working directory // ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers if (await ctx.ui.confirm("Trust project?", event.cwd)) { - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } + return { trusted: "undecided" }; }); ``` -A `project_trust` handler must return `{ trusted }`; if a user/global or CLI extension registers this handler, it owns the decision. The first returned decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist the decision; otherwise it applies only to the current process. Check `ctx.hasUI` before prompting. If no extension handles `project_trust`, normal trust resolution continues, including the built-in trust prompt when UI is available. +A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues, including the built-in trust prompt when UI is available. ### Resource Events @@ -2586,7 +2587,7 @@ All examples in [examples/extensions/](../examples/extensions/). | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | **Events & Gates** ||| | `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | -| `project-trust.ts` | Decide project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | +| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result | | `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` | | `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | diff --git a/packages/coding-agent/examples/extensions/project-trust.ts b/packages/coding-agent/examples/extensions/project-trust.ts index 66020a548..6234bc249 100644 --- a/packages/coding-agent/examples/extensions/project-trust.ts +++ b/packages/coding-agent/examples/extensions/project-trust.ts @@ -20,13 +20,14 @@ export default function (pi: ExtensionAPI) { loadCount++; // Multiple handlers in one extension are allowed. The first handler that returns - // { trusted } wins and suppresses the built-in trust prompt. A project_trust - // handler must return a decision. + // { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in + // trust prompt. Return { trusted: "undecided" } to let another handler or the + // built-in flow decide. pi.on("project_trust", async (event, ctx): Promise => { ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info"); if (!ctx.hasUI) { - return { trusted: false }; + return { trusted: "undecided" }; } const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [ @@ -34,23 +35,27 @@ export default function (pi: ExtensionAPI) { "Trust with note and remember", "Trust this session", "Do not trust this session", + "Let built-in prompt decide", ]); if (choice === "Trust with note and remember") { const note = await ctx.ui.input("Project trust note", "Optional note for this demo"); ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info"); - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } if (choice === "Trust and remember") { - return { trusted: true, remember: true }; + return { trusted: "yes", remember: true }; } if (choice === "Trust this session") { - return { trusted: true }; + return { trusted: "yes" }; } if (choice === "Do not trust this session") { - return { trusted: false }; + return { trusted: "no" }; } - return { trusted: false }; + if (choice === "Let built-in prompt decide") { + return { trusted: "undecided" }; + } + return { trusted: "undecided" }; }); pi.on("session_start", (_event, ctx) => { diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index a20887ed8..3ed7a6625 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -100,6 +100,7 @@ export type { ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, + ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, // Provider Registration diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index db4ba0be3..3030b65d1 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -202,13 +202,16 @@ export async function emitProjectTrustEvent( const errors: ExtensionError[] = []; for (const ext of extensionsResult.extensions) { // A single extension may register multiple handlers for the same event. - // The first project_trust handler that returns a decision wins. + // The first project_trust handler that returns yes/no wins; undecided falls through. const handlers = ext.handlers.get("project_trust"); if (!handlers || handlers.length === 0) continue; for (const handler of handlers) { try { const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; + if (handlerResult.trusted === "undecided") { + continue; + } return { result: handlerResult, errors }; } catch (error) { errors.push({ diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9fd3ffdfd..7575d8af3 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -503,8 +503,10 @@ export interface ProjectTrustEvent { cwd: string; } +export type ProjectTrustEventDecision = "yes" | "no" | "undecided"; + export interface ProjectTrustEventResult { - trusted: boolean; + trusted: ProjectTrustEventDecision; remember?: boolean; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 677c030b2..398f5430d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -100,6 +100,7 @@ export type { MessageRenderOptions, ProjectTrustContext, ProjectTrustEvent, + ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 4387af6d4..335d814aa 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -648,10 +648,11 @@ async function resolveProjectTrusted(options: { options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); } if (result) { + const trusted = result.trusted === "yes"; if (result.remember === true) { - options.trustStore.set(options.cwd, result.trusted); + options.trustStore.set(options.cwd, trusted); } - return result.trusted; + return trusted; } } diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 0432ec39e..f49393672 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -7,8 +7,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; -import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; -import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts"; import type { ExtensionActions, ExtensionContextActions, @@ -85,6 +85,45 @@ describe("ExtensionRunner", () => { getSystemPrompt: () => "", }; + describe("project_trust", () => { + it("continues past undecided handlers and returns the first yes/no decision", async () => { + const undecidedPath = path.join(extensionsDir, "undecided.ts"); + const decidedPath = path.join(extensionsDir, "decided.ts"); + fs.writeFileSync( + undecidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "undecided", remember: true })); +}`, + ); + fs.writeFileSync( + decidedPath, + `export default function(pi) { + pi.on("project_trust", () => ({ trusted: "no", remember: true })); +}`, + ); + + const extensionsResult = await loadExtensions([undecidedPath, decidedPath], tempDir); + const result = await emitProjectTrustEvent( + extensionsResult, + { type: "project_trust", cwd: tempDir }, + { + cwd: tempDir, + mode: "tui", + hasUI: false, + ui: { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + }, + }, + ); + + expect(result.result).toEqual({ trusted: "no", remember: true }); + expect(result.errors).toEqual([]); + }); + }); + describe("shortcut conflicts", () => { it("warns when extension shortcut conflicts with built-in", async () => { const extCode = ` diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 779f60640..abbe89755 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -199,7 +199,7 @@ Project skill`, join(userExtDir, "user.ts"), `globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1; export default function(pi) { - pi.on("project_trust", () => ({ trusted: true })); + pi.on("project_trust", () => ({ trusted: "yes" })); pi.registerCommand("user-trust", { description: "user trust", handler: async () => {},