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 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)).

View file

@ -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` |

View file

@ -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<ProjectTrustEventResult> => {
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) => {

View file

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

View file

@ -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({

View file

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

View file

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

View file

@ -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;
}
}

View file

@ -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 = `

View file

@ -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 () => {},