feat(coding-agent): allow project trust extensions to defer

This commit is contained in:
Mario Zechner
2026-06-08 13:38:29 +02:00
parent 085a08582f
commit d8aef0feff
10 changed files with 72 additions and 19 deletions

View File

@@ -4,7 +4,7 @@
### Added ### 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 project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)).
- Added the latest prompt cache hit rate to the interactive footer. - Added the latest prompt cache hit rate to the interactive footer.
- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)). - Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)).

View File

@@ -346,12 +346,13 @@ pi.on("project_trust", async (event, ctx) => {
// event.cwd - current working directory // event.cwd - current working directory
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers // 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)) { 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 ### Resource Events
@@ -2586,7 +2587,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` | | `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
| **Events & Gates** ||| | **Events & Gates** |||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | | `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")` | | `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")` | | `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` | | `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |

View File

@@ -20,13 +20,14 @@ export default function (pi: ExtensionAPI) {
loadCount++; loadCount++;
// Multiple handlers in one extension are allowed. The first handler that returns // Multiple handlers in one extension are allowed. The first handler that returns
// { trusted } wins and suppresses the built-in trust prompt. A project_trust // { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in
// handler must return a decision. // trust prompt. Return { trusted: "undecided" } to let another handler or the
// built-in flow decide.
pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => { pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info"); ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info");
if (!ctx.hasUI) { if (!ctx.hasUI) {
return { trusted: false }; return { trusted: "undecided" };
} }
const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [ 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 with note and remember",
"Trust this session", "Trust this session",
"Do not trust this session", "Do not trust this session",
"Let built-in prompt decide",
]); ]);
if (choice === "Trust with note and remember") { if (choice === "Trust with note and remember") {
const note = await ctx.ui.input("Project trust note", "Optional note for this demo"); 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"); 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") { if (choice === "Trust and remember") {
return { trusted: true, remember: true }; return { trusted: "yes", remember: true };
} }
if (choice === "Trust this session") { if (choice === "Trust this session") {
return { trusted: true }; return { trusted: "yes" };
} }
if (choice === "Do not trust this session") { 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) => { pi.on("session_start", (_event, ctx) => {

View File

@@ -100,6 +100,7 @@ export type {
ModelSelectSource, ModelSelectSource,
ProjectTrustContext, ProjectTrustContext,
ProjectTrustEvent, ProjectTrustEvent,
ProjectTrustEventDecision,
ProjectTrustEventResult, ProjectTrustEventResult,
ProjectTrustHandler, ProjectTrustHandler,
// Provider Registration // Provider Registration

View File

@@ -202,13 +202,16 @@ export async function emitProjectTrustEvent(
const errors: ExtensionError[] = []; const errors: ExtensionError[] = [];
for (const ext of extensionsResult.extensions) { for (const ext of extensionsResult.extensions) {
// A single extension may register multiple handlers for the same event. // 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"); const handlers = ext.handlers.get("project_trust");
if (!handlers || handlers.length === 0) continue; if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) { for (const handler of handlers) {
try { try {
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
if (handlerResult.trusted === "undecided") {
continue;
}
return { result: handlerResult, errors }; return { result: handlerResult, errors };
} catch (error) { } catch (error) {
errors.push({ errors.push({

View File

@@ -503,8 +503,10 @@ export interface ProjectTrustEvent {
cwd: string; cwd: string;
} }
export type ProjectTrustEventDecision = "yes" | "no" | "undecided";
export interface ProjectTrustEventResult { export interface ProjectTrustEventResult {
trusted: boolean; trusted: ProjectTrustEventDecision;
remember?: boolean; remember?: boolean;
} }

View File

@@ -100,6 +100,7 @@ export type {
MessageRenderOptions, MessageRenderOptions,
ProjectTrustContext, ProjectTrustContext,
ProjectTrustEvent, ProjectTrustEvent,
ProjectTrustEventDecision,
ProjectTrustEventResult, ProjectTrustEventResult,
ProjectTrustHandler, ProjectTrustHandler,
ProviderConfig, ProviderConfig,

View File

@@ -648,10 +648,11 @@ async function resolveProjectTrusted(options: {
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
} }
if (result) { if (result) {
const trusted = result.trusted === "yes";
if (result.remember === true) { if (result.remember === true) {
options.trustStore.set(options.cwd, result.trusted); options.trustStore.set(options.cwd, trusted);
} }
return result.trusted; return trusted;
} }
} }

View File

@@ -7,8 +7,8 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts"; import { AuthStorage } from "../src/core/auth-storage.ts";
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts"; import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts"; import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts";
import type { import type {
ExtensionActions, ExtensionActions,
ExtensionContextActions, ExtensionContextActions,
@@ -85,6 +85,45 @@ describe("ExtensionRunner", () => {
getSystemPrompt: () => "", 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", () => { describe("shortcut conflicts", () => {
it("warns when extension shortcut conflicts with built-in", async () => { it("warns when extension shortcut conflicts with built-in", async () => {
const extCode = ` const extCode = `

View File

@@ -199,7 +199,7 @@ Project skill`,
join(userExtDir, "user.ts"), join(userExtDir, "user.ts"),
`globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1; `globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1;
export default function(pi) { export default function(pi) {
pi.on("project_trust", () => ({ trusted: true })); pi.on("project_trust", () => ({ trusted: "yes" }));
pi.registerCommand("user-trust", { pi.registerCommand("user-trust", {
description: "user trust", description: "user trust",
handler: async () => {}, handler: async () => {},