@ian-pascoe/pi-guardian 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/package.json +59 -0
- package/skills/pi-guardian/SKILL.md +24 -0
- package/src/guardian-assessment.ts +118 -0
- package/src/guardian-audit.ts +169 -0
- package/src/guardian-calibration.ts +32 -0
- package/src/guardian-command.ts +104 -0
- package/src/guardian-dialog.ts +83 -0
- package/src/guardian-evidence.ts +370 -0
- package/src/guardian-extension.ts +398 -0
- package/src/guardian-gate.ts +608 -0
- package/src/guardian-menu.ts +556 -0
- package/src/guardian-pi-resources.ts +46 -0
- package/src/guardian-prompt.ts +104 -0
- package/src/guardian-rendering.ts +240 -0
- package/src/guardian-review.ts +215 -0
- package/src/guardian-root-registry.ts +77 -0
- package/src/guardian-settings.ts +232 -0
- package/src/index.ts +1 -0
- package/src/safe-command.ts +147 -0
- package/src/sensitive-paths.ts +205 -0
- package/src/tool-policy.ts +94 -0
- package/src/troubleshooting-skill.ts +9 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { validateToolArguments, type ToolCall } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type AgentSession,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
type SessionEntry,
|
|
9
|
+
type ToolCallEventResult,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { Value } from "typebox/value";
|
|
13
|
+
import { rejectionReason } from "./guardian-assessment.js";
|
|
14
|
+
import {
|
|
15
|
+
auditArguments,
|
|
16
|
+
recordedOverrides,
|
|
17
|
+
reviewEntryType,
|
|
18
|
+
type ReviewEntry,
|
|
19
|
+
type ReviewOutcome,
|
|
20
|
+
} from "./guardian-audit.js";
|
|
21
|
+
import { overrideDialogs } from "./guardian-dialog.js";
|
|
22
|
+
import {
|
|
23
|
+
renderReviewedCall,
|
|
24
|
+
selectEvidence,
|
|
25
|
+
textTokens,
|
|
26
|
+
typedUserMessages,
|
|
27
|
+
userMessageKey,
|
|
28
|
+
type IssuingCall,
|
|
29
|
+
type RootUserMessage,
|
|
30
|
+
type ToolInput,
|
|
31
|
+
} from "./guardian-evidence.js";
|
|
32
|
+
import { contextFiles, loadedResourcePaths } from "./guardian-pi-resources.js";
|
|
33
|
+
import { guardianSystemPrompt } from "./guardian-prompt.js";
|
|
34
|
+
import { resolveGuardianModel, runGuardianReview, type ReviewResult } from "./guardian-review.js";
|
|
35
|
+
import { rootSession, type GuardedSessionRole } from "./guardian-root-registry.js";
|
|
36
|
+
import { calibratedFactor, fallbackTokenFactor } from "./guardian-calibration.js";
|
|
37
|
+
import { evidenceBudget, type GuardianConfig } from "./guardian-settings.js";
|
|
38
|
+
import type { SensitivePathContext } from "./sensitive-paths.js";
|
|
39
|
+
import { resolveToolPolicy, type ResolvedToolPolicy } from "./tool-policy.js";
|
|
40
|
+
import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
|
|
41
|
+
|
|
42
|
+
/** What the review gate reads from the extension that owns the session. */
|
|
43
|
+
export interface ReviewGateHost {
|
|
44
|
+
session(): AgentSession | undefined;
|
|
45
|
+
/** Why the session is unavailable, when it is. */
|
|
46
|
+
unavailable(): string;
|
|
47
|
+
role(): GuardedSessionRole;
|
|
48
|
+
/** Settings to enforce, or the error that makes every non-read-only call fail closed. */
|
|
49
|
+
settings(): { config: GuardianConfig; error: string | undefined };
|
|
50
|
+
/** The set of tools under review changed. */
|
|
51
|
+
reviewingChanged(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The running review gate. */
|
|
55
|
+
export interface ReviewGate {
|
|
56
|
+
/** Tool names currently under review. */
|
|
57
|
+
reviewing(): string[];
|
|
58
|
+
/** Forget a replaced session's calls and reviews without recording them. */
|
|
59
|
+
reset(): void;
|
|
60
|
+
/** Record held and unconsumed reviews before the session ends. */
|
|
61
|
+
flush(): void;
|
|
62
|
+
/** The messages this session's user typed, for its Child Agents and Advisors. */
|
|
63
|
+
typedUserMessages(): RootUserMessage[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One call as seen by Guardian's `tool_call` handler. */
|
|
67
|
+
interface SeenCall {
|
|
68
|
+
toolCallId: string;
|
|
69
|
+
toolName: string;
|
|
70
|
+
input: ToolInput;
|
|
71
|
+
parentToolCallId: string | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A Guardian Review started when the assistant message ended, ahead of its call's preflight. */
|
|
75
|
+
interface Prefetch {
|
|
76
|
+
/** The reviewed snapshot of the call, as validated for its tool. */
|
|
77
|
+
call: SeenCall;
|
|
78
|
+
controller: AbortController;
|
|
79
|
+
result: Promise<ReviewResult>;
|
|
80
|
+
consumed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An allowed call's audit entry, held until its result shows it ran and whether it drifted. */
|
|
84
|
+
interface PendingAudit {
|
|
85
|
+
entry: ReviewEntry;
|
|
86
|
+
approved: ToolInput;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Built-in tools that only read; the only calls that run while settings are unreadable. */
|
|
90
|
+
const readOnlyBuiltIns: ReadonlySet<string> = new Set(["read", "grep", "find", "ls"]);
|
|
91
|
+
/** Early reviews running at once; the rest wait for a slot. */
|
|
92
|
+
const maxConcurrentPrefetches = 4;
|
|
93
|
+
/** Tokens kept free for the Guardian's reasoning and reply. */
|
|
94
|
+
const outputReserveTokens = 8_192;
|
|
95
|
+
/** Pi's branch summarization uses the same fallback for models without a declared window. */
|
|
96
|
+
const fallbackWindow = 128_000;
|
|
97
|
+
/** Remembered extension-sent inputs awaiting their user message. */
|
|
98
|
+
const maxPendingInputs = 32;
|
|
99
|
+
|
|
100
|
+
/** Custom entry marking a user message that an extension sent, so it is untrusted evidence. */
|
|
101
|
+
export const extensionMessageEntryType = "pi-guardian-extension-message";
|
|
102
|
+
const extensionMessageSchema = Type.Object({ version: Type.Literal(1), key: Type.String() });
|
|
103
|
+
|
|
104
|
+
/** Keys of user messages extensions sent on this branch. */
|
|
105
|
+
function extensionMessages(branch: readonly SessionEntry[]): Set<string> {
|
|
106
|
+
return new Set(
|
|
107
|
+
branch.flatMap((entry) =>
|
|
108
|
+
entry.type === "custom" &&
|
|
109
|
+
entry.customType === extensionMessageEntryType &&
|
|
110
|
+
Value.Check(extensionMessageSchema, entry.data)
|
|
111
|
+
? [entry.data.key]
|
|
112
|
+
: [],
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Run at most `max` tasks at once, in arrival order. */
|
|
118
|
+
function limiter(max: number): <T>(task: () => Promise<T>) => Promise<T> {
|
|
119
|
+
let active = 0;
|
|
120
|
+
const waiting: (() => void)[] = [];
|
|
121
|
+
return async (task) => {
|
|
122
|
+
if (active < max) active++;
|
|
123
|
+
else await new Promise<void>((resolve) => waiting.push(resolve));
|
|
124
|
+
try {
|
|
125
|
+
return await task();
|
|
126
|
+
} finally {
|
|
127
|
+
// Hand the slot straight to the next waiter so no newcomer can overtake it.
|
|
128
|
+
const next = waiting.shift();
|
|
129
|
+
if (next) next();
|
|
130
|
+
else active--;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function failed(failure: string): ReviewResult {
|
|
136
|
+
return { kind: "failed", failure, model: null, durationMs: 0, usage: null, cost: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function agentLabel(role: GuardedSessionRole): string {
|
|
140
|
+
if (role.kind === "main") return "the main Pi agent";
|
|
141
|
+
if (role.kind === "child")
|
|
142
|
+
return "a Minimal Subagents Child Agent (its task comes from another agent, not the user)";
|
|
143
|
+
return "an Advisor (its requests come from Pi, not the user)";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Gate tool calls with Guardian Reviews: early reviews, Tool Policies, Outcomes, and audits. */
|
|
147
|
+
export function installReviewGate(pi: ExtensionAPI, host: ReviewGateHost): ReviewGate {
|
|
148
|
+
let streak = 0;
|
|
149
|
+
/** Tool names under review, keyed per review so a superseded review cannot clear another. */
|
|
150
|
+
const reviewing = new Map<symbol, string>();
|
|
151
|
+
const calls = new Map<string, SeenCall>();
|
|
152
|
+
const prefetched = new Map<string, Prefetch>();
|
|
153
|
+
const pendingAudits = new Map<string, PendingAudit>();
|
|
154
|
+
const extensionInputs: string[] = [];
|
|
155
|
+
const askOverride = overrideDialogs();
|
|
156
|
+
/** Calibrated real-to-estimated token factor per Guardian model, for this process. */
|
|
157
|
+
const tokenFactors = new Map<string, number>();
|
|
158
|
+
const prefetchSlot = limiter(maxConcurrentPrefetches);
|
|
159
|
+
|
|
160
|
+
function notify(ctx: ExtensionContext, text: string, level: "warning" | "error"): void {
|
|
161
|
+
if (!ctx.hasUI) return;
|
|
162
|
+
try {
|
|
163
|
+
ctx.ui.notify(text, level);
|
|
164
|
+
} catch {
|
|
165
|
+
// A replaced session's UI is stale; the review entry remains the durable record.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function append(entry: ReviewEntry): void {
|
|
170
|
+
try {
|
|
171
|
+
pi.appendEntry(reviewEntryType, entry);
|
|
172
|
+
} catch {
|
|
173
|
+
// The session ended while a review settled; nothing remains to record it in.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function pathContext(ctx: ExtensionContext): SensitivePathContext {
|
|
178
|
+
const session = host.session();
|
|
179
|
+
return {
|
|
180
|
+
cwd: ctx.cwd,
|
|
181
|
+
piDirectories: [getAgentDir(), ctx.sessionManager.getSessionDir()].filter(Boolean),
|
|
182
|
+
loadedResources: session ? loadedResourcePaths(session) : [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The call's own Tool Policy. */
|
|
187
|
+
function ownPolicy(
|
|
188
|
+
config: GuardianConfig,
|
|
189
|
+
paths: SensitivePathContext,
|
|
190
|
+
toolName: string,
|
|
191
|
+
input: ToolInput,
|
|
192
|
+
): ResolvedToolPolicy {
|
|
193
|
+
return resolveToolPolicy({
|
|
194
|
+
toolName,
|
|
195
|
+
input,
|
|
196
|
+
configured: config.tools,
|
|
197
|
+
safeCommands: config.safeCommands,
|
|
198
|
+
annotations: pi.getAllTools().find((tool) => tool.name === toolName)?.annotations,
|
|
199
|
+
paths,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The other tool calls of the assistant message that issued `toolCallId`. */
|
|
204
|
+
function batchOf(toolCallId: string): ToolCall[] {
|
|
205
|
+
for (const message of host.session()?.messages.toReversed() ?? []) {
|
|
206
|
+
if (message.role !== "assistant") continue;
|
|
207
|
+
const blocks = message.content.flatMap((part) => (part.type === "toolCall" ? [part] : []));
|
|
208
|
+
if (blocks.some((block) => block.id === toolCallId)) return blocks;
|
|
209
|
+
}
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The call's Tool Policy. A top-level edit or write that its default would allow is reviewed
|
|
215
|
+
* when its batch also has a call that is not allowed without review: Pi may run them in
|
|
216
|
+
* parallel, and that call could swap the target for a link while the edit is pending.
|
|
217
|
+
*/
|
|
218
|
+
function policyFor(
|
|
219
|
+
ctx: ExtensionContext,
|
|
220
|
+
config: GuardianConfig,
|
|
221
|
+
call: SeenCall,
|
|
222
|
+
batch: readonly ToolCall[] = call.parentToolCallId ? [] : batchOf(call.toolCallId),
|
|
223
|
+
): ResolvedToolPolicy {
|
|
224
|
+
const paths = pathContext(ctx);
|
|
225
|
+
const own = ownPolicy(config, paths, call.toolName, call.input);
|
|
226
|
+
if (own.policy !== "allow" || own.source !== "default") return own;
|
|
227
|
+
if (call.toolName !== "edit" && call.toolName !== "write") return own;
|
|
228
|
+
const risky = batch.some(
|
|
229
|
+
(other) =>
|
|
230
|
+
other.id !== call.toolCallId &&
|
|
231
|
+
ownPolicy(config, paths, other.name, other.arguments).policy !== "allow",
|
|
232
|
+
);
|
|
233
|
+
return risky
|
|
234
|
+
? {
|
|
235
|
+
policy: "review",
|
|
236
|
+
source: "default",
|
|
237
|
+
detail: "it shares a tool batch with a call that is not allowed without review",
|
|
238
|
+
}
|
|
239
|
+
: own;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The issuing call of a nested call, from calls seen this run or the transcript. */
|
|
243
|
+
function issuingCall(parentToolCallId: string | undefined): IssuingCall | undefined {
|
|
244
|
+
if (parentToolCallId === undefined) return undefined;
|
|
245
|
+
const seen = calls.get(parentToolCallId);
|
|
246
|
+
if (seen) return { toolName: seen.toolName, input: seen.input };
|
|
247
|
+
const block = batchOf(parentToolCallId).find((part) => part.id === parentToolCallId);
|
|
248
|
+
if (block) return { toolName: block.name, input: block.arguments };
|
|
249
|
+
return { toolName: "unknown", input: { toolCallId: parentToolCallId } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Run one Guardian Review of `call` against the current evidence. */
|
|
253
|
+
async function review(
|
|
254
|
+
ctx: ExtensionContext,
|
|
255
|
+
config: GuardianConfig,
|
|
256
|
+
call: SeenCall,
|
|
257
|
+
reason: string | undefined,
|
|
258
|
+
signal: AbortSignal | undefined,
|
|
259
|
+
): Promise<ReviewResult> {
|
|
260
|
+
const resolved = resolveGuardianModel(config.model, ctx.modelRegistry, ctx.model);
|
|
261
|
+
if (!resolved.ok) return { ...failed(resolved.failure), model: resolved.model };
|
|
262
|
+
const session = host.session();
|
|
263
|
+
if (!session) return failed(host.unavailable());
|
|
264
|
+
const role = host.role();
|
|
265
|
+
const systemPrompt = guardianSystemPrompt(config.policy, config.verbose);
|
|
266
|
+
const reviewed = renderReviewedCall({
|
|
267
|
+
toolName: call.toolName,
|
|
268
|
+
input: call.input,
|
|
269
|
+
cwd: ctx.cwd,
|
|
270
|
+
agent: agentLabel(role),
|
|
271
|
+
parent: issuingCall(call.parentToolCallId),
|
|
272
|
+
reason,
|
|
273
|
+
});
|
|
274
|
+
const name = `${resolved.model.provider}/${resolved.model.id}`;
|
|
275
|
+
// Pi's chars/4 estimate, scaled by this model's calibrated factor, approximates real tokens.
|
|
276
|
+
const factor = tokenFactors.get(name) ?? fallbackTokenFactor;
|
|
277
|
+
const tokens = (text: string) => Math.ceil(textTokens(text) * factor);
|
|
278
|
+
// The Reviewed Call is never shortened: it must fit beside the policy and the reply.
|
|
279
|
+
const capacity =
|
|
280
|
+
(resolved.model.contextWindow || fallbackWindow) - tokens(systemPrompt) - outputReserveTokens;
|
|
281
|
+
const callTokens = tokens(reviewed);
|
|
282
|
+
if (callTokens > capacity)
|
|
283
|
+
return {
|
|
284
|
+
...failed(
|
|
285
|
+
`the call is too large for Guardian model ${name} to review in full (about ${callTokens} tokens; at most ${Math.max(0, capacity)} fit), and Guardian never reviews a shortened call`,
|
|
286
|
+
),
|
|
287
|
+
model: name,
|
|
288
|
+
};
|
|
289
|
+
const branch = ctx.sessionManager.getBranch();
|
|
290
|
+
const evidence = selectEvidence({
|
|
291
|
+
sources: session.messages,
|
|
292
|
+
trustUserMessages: role.kind === "main",
|
|
293
|
+
contextFiles: contextFiles(session, getAgentDir()),
|
|
294
|
+
extensionMessages: extensionMessages(branch),
|
|
295
|
+
overrides: recordedOverrides(branch),
|
|
296
|
+
rootUserMessages:
|
|
297
|
+
role.kind === "main" ? [] : (rootSession(role.rootSessionId)?.userMessages() ?? []),
|
|
298
|
+
// Selection counts chars/4, so the budget in real tokens is scaled down by the factor.
|
|
299
|
+
budgetTokens: Math.floor(
|
|
300
|
+
Math.min(
|
|
301
|
+
evidenceBudget(config.evidenceBudgetTokens, resolved.model.contextWindow),
|
|
302
|
+
capacity - callTokens,
|
|
303
|
+
) / factor,
|
|
304
|
+
),
|
|
305
|
+
});
|
|
306
|
+
const blocks = [...evidence.blocks, reviewed];
|
|
307
|
+
const estimated = textTokens(systemPrompt) + blocks.reduce((sum, b) => sum + textTokens(b), 0);
|
|
308
|
+
const key = Symbol(call.toolCallId);
|
|
309
|
+
reviewing.set(key, call.toolName);
|
|
310
|
+
host.reviewingChanged();
|
|
311
|
+
try {
|
|
312
|
+
const result = await runGuardianReview({
|
|
313
|
+
registry: ctx.modelRegistry,
|
|
314
|
+
model: resolved.model,
|
|
315
|
+
thinkingLevel: config.thinkingLevel,
|
|
316
|
+
context: {
|
|
317
|
+
systemPrompt,
|
|
318
|
+
messages: [
|
|
319
|
+
{
|
|
320
|
+
role: "user",
|
|
321
|
+
content: blocks.map((text) => ({ type: "text", text })),
|
|
322
|
+
// A fixed timestamp keeps successive requests' prefixes byte-identical.
|
|
323
|
+
timestamp: 0,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
timeoutMs: config.reviewTimeoutMs,
|
|
328
|
+
signal,
|
|
329
|
+
sessionId: `pi-guardian:${ctx.sessionManager.getSessionId()}`,
|
|
330
|
+
});
|
|
331
|
+
if (result.promptTokens !== undefined)
|
|
332
|
+
tokenFactors.set(name, calibratedFactor(factor, estimated, result.promptTokens));
|
|
333
|
+
return result;
|
|
334
|
+
} finally {
|
|
335
|
+
reviewing.delete(key);
|
|
336
|
+
host.reviewingChanged();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function auditEntry(call: SeenCall, result: ReviewResult, outcome: ReviewOutcome): ReviewEntry {
|
|
341
|
+
const entry: ReviewEntry = {
|
|
342
|
+
version: 1,
|
|
343
|
+
toolName: call.toolName,
|
|
344
|
+
toolCallId: call.toolCallId,
|
|
345
|
+
parentToolCallId: call.parentToolCallId ?? null,
|
|
346
|
+
...auditArguments(call.input),
|
|
347
|
+
risk: result.kind === "assessed" ? result.assessment.risk : null,
|
|
348
|
+
authorization: result.kind === "assessed" ? result.assessment.authorization : null,
|
|
349
|
+
outcome,
|
|
350
|
+
rationale: result.kind === "assessed" ? result.assessment.rationale : null,
|
|
351
|
+
failure: result.kind === "failed" ? result.failure : null,
|
|
352
|
+
userOverride: false,
|
|
353
|
+
blocked: outcome !== "allowed",
|
|
354
|
+
model: result.model,
|
|
355
|
+
durationMs: result.durationMs,
|
|
356
|
+
usage: result.usage,
|
|
357
|
+
cost: result.cost,
|
|
358
|
+
};
|
|
359
|
+
if (result.retried) entry.retried = true;
|
|
360
|
+
return entry;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Hold an allowed call's entry until its result shows that it ran. */
|
|
364
|
+
function allow(call: SeenCall, entry: ReviewEntry): undefined {
|
|
365
|
+
pendingAudits.set(call.toolCallId, { entry, approved: structuredClone(call.input) });
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function blocked(
|
|
370
|
+
config: GuardianConfig,
|
|
371
|
+
entry: ReviewEntry,
|
|
372
|
+
reason: string,
|
|
373
|
+
): ToolCallEventResult {
|
|
374
|
+
append(entry);
|
|
375
|
+
streak++;
|
|
376
|
+
const result: ToolCallEventResult = { block: true, reason };
|
|
377
|
+
if (config.maxConsecutiveRejections > 0 && streak >= config.maxConsecutiveRejections)
|
|
378
|
+
result.terminate = true;
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Turn a review into the call's fate: allow, ask the user, or block. */
|
|
383
|
+
async function settle(
|
|
384
|
+
ctx: ExtensionContext,
|
|
385
|
+
config: GuardianConfig,
|
|
386
|
+
call: SeenCall,
|
|
387
|
+
result: ReviewResult,
|
|
388
|
+
): Promise<ToolCallEventResult | undefined> {
|
|
389
|
+
if (result.kind === "aborted") {
|
|
390
|
+
append(auditEntry(call, result, "aborted"));
|
|
391
|
+
return { block: true, reason: "Guardian Review was aborted; the call did not run." };
|
|
392
|
+
}
|
|
393
|
+
if (result.kind === "assessed" && result.outcome === "allowed")
|
|
394
|
+
return allow(call, auditEntry(call, result, "allowed"));
|
|
395
|
+
const request = {
|
|
396
|
+
toolName: call.toolName,
|
|
397
|
+
input: call.input,
|
|
398
|
+
parent: issuingCall(call.parentToolCallId),
|
|
399
|
+
};
|
|
400
|
+
if (result.kind === "assessed") {
|
|
401
|
+
const { risk, authorization, rationale } = result.assessment;
|
|
402
|
+
notify(ctx, `Guardian rejected ${call.toolName} (${risk} risk): ${rationale}`, "warning");
|
|
403
|
+
const entry = auditEntry(call, result, "rejected");
|
|
404
|
+
if (
|
|
405
|
+
config.onDeny === "ask" &&
|
|
406
|
+
(await askOverride(ctx, {
|
|
407
|
+
...request,
|
|
408
|
+
headline: `Guardian rejected ${call.toolName} — risk ${risk}, authorization ${authorization}\n${rationale}`,
|
|
409
|
+
}))
|
|
410
|
+
)
|
|
411
|
+
return allow(call, { ...entry, userOverride: true, blocked: false });
|
|
412
|
+
return blocked(config, entry, rejectionReason(result.assessment));
|
|
413
|
+
}
|
|
414
|
+
notify(ctx, `Guardian could not review ${call.toolName}: ${result.failure}`, "warning");
|
|
415
|
+
const entry = auditEntry(call, result, "failed");
|
|
416
|
+
if (
|
|
417
|
+
await askOverride(ctx, {
|
|
418
|
+
...request,
|
|
419
|
+
headline: `Guardian could not review ${call.toolName}: ${result.failure}`,
|
|
420
|
+
})
|
|
421
|
+
)
|
|
422
|
+
return allow(call, { ...entry, userOverride: true, blocked: false });
|
|
423
|
+
return blocked(
|
|
424
|
+
config,
|
|
425
|
+
entry,
|
|
426
|
+
`Guardian could not review this ${call.toolName} call, so it was blocked: ${result.failure}. Do not retry it or work around it; tell the user that Guardian could not review the action and ask how to proceed.\n\n${TROUBLESHOOTING_HINT}`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Record an unconsumed early review once it settles. */
|
|
431
|
+
function discard(prefetch: Prefetch): void {
|
|
432
|
+
if (prefetch.consumed) return;
|
|
433
|
+
prefetch.consumed = true;
|
|
434
|
+
prefetch.controller.abort();
|
|
435
|
+
void prefetch.result.then((result) => {
|
|
436
|
+
if (result.usage || result.durationMs > 0)
|
|
437
|
+
append(auditEntry(prefetch.call, result, "unused"));
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Start early reviews for a response's calls, so parallel calls are reviewed concurrently. */
|
|
442
|
+
function prefetch(ctx: ExtensionContext, blocks: readonly ToolCall[]): void {
|
|
443
|
+
const { config, error } = host.settings();
|
|
444
|
+
if (!config.enabled || error) return;
|
|
445
|
+
const tools = pi.getAllTools();
|
|
446
|
+
for (const block of blocks) {
|
|
447
|
+
if (prefetched.has(block.id)) continue;
|
|
448
|
+
const tool = tools.find((candidate) => candidate.name === block.name);
|
|
449
|
+
// Pi never runs calls to unknown tools or with invalid arguments; review those on arrival.
|
|
450
|
+
if (!tool) continue;
|
|
451
|
+
let input: ToolInput;
|
|
452
|
+
try {
|
|
453
|
+
input = validateToolArguments(tool, block);
|
|
454
|
+
} catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const call: SeenCall = {
|
|
458
|
+
toolCallId: block.id,
|
|
459
|
+
toolName: block.name,
|
|
460
|
+
input: structuredClone(input),
|
|
461
|
+
parentToolCallId: undefined,
|
|
462
|
+
};
|
|
463
|
+
const policy = policyFor(ctx, config, call, blocks);
|
|
464
|
+
if (policy.policy !== "review") continue;
|
|
465
|
+
const controller = new AbortController();
|
|
466
|
+
const signal = ctx.signal
|
|
467
|
+
? AbortSignal.any([ctx.signal, controller.signal])
|
|
468
|
+
: controller.signal;
|
|
469
|
+
prefetched.set(block.id, {
|
|
470
|
+
call,
|
|
471
|
+
controller,
|
|
472
|
+
result: prefetchSlot(() => review(ctx, config, call, policy.detail, signal)),
|
|
473
|
+
consumed: false,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
pi.on("input", (event) => {
|
|
479
|
+
if (event.source !== "extension" || !event.text) return;
|
|
480
|
+
extensionInputs.push(event.text);
|
|
481
|
+
if (extensionInputs.length > maxPendingInputs) extensionInputs.shift();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
pi.on("message_end", (event, ctx) => {
|
|
485
|
+
const { message } = event;
|
|
486
|
+
if (message.role === "user") {
|
|
487
|
+
// Mark the user message an extension sent so evidence treats it as untrusted.
|
|
488
|
+
const text = Array.isArray(message.content)
|
|
489
|
+
? message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
|
490
|
+
: message.content;
|
|
491
|
+
const index = extensionInputs.findIndex((input) => text.startsWith(input));
|
|
492
|
+
if (index < 0) return;
|
|
493
|
+
extensionInputs.splice(index, 1);
|
|
494
|
+
pi.appendEntry(extensionMessageEntryType, { version: 1, key: userMessageKey(message) });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Pi executes tool calls only from a completed tool-use response.
|
|
498
|
+
if (message.role !== "assistant" || message.stopReason !== "toolUse") return;
|
|
499
|
+
prefetch(
|
|
500
|
+
ctx,
|
|
501
|
+
message.content.flatMap((part) => (part.type === "toolCall" ? [part] : [])),
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
506
|
+
const call: SeenCall = {
|
|
507
|
+
toolCallId: event.toolCallId,
|
|
508
|
+
toolName: event.toolName,
|
|
509
|
+
input: event.input,
|
|
510
|
+
parentToolCallId: event.parentToolCallId,
|
|
511
|
+
};
|
|
512
|
+
calls.set(call.toolCallId, { ...call, input: structuredClone(call.input) });
|
|
513
|
+
const early = prefetched.get(call.toolCallId);
|
|
514
|
+
const { config, error } = host.settings();
|
|
515
|
+
if (error) {
|
|
516
|
+
// Unreadable settings may have held deny or review rules: only built-in reads still run.
|
|
517
|
+
if (early) discard(early);
|
|
518
|
+
if (readOnlyBuiltIns.has(call.toolName)) return undefined;
|
|
519
|
+
return settle(ctx, config, call, failed(`Guardian settings are unavailable: ${error}`));
|
|
520
|
+
}
|
|
521
|
+
if (!config.enabled) {
|
|
522
|
+
if (early) discard(early);
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
const policy = policyFor(ctx, config, call);
|
|
526
|
+
if (policy.policy !== "review" && early) discard(early);
|
|
527
|
+
if (policy.policy === "allow") return undefined;
|
|
528
|
+
if (policy.policy === "deny")
|
|
529
|
+
return {
|
|
530
|
+
block: true,
|
|
531
|
+
reason: `The ${call.toolName} tool is denied by Guardian's Tool Policy; the call did not run. Do not work around it; ask the user if this action is needed.`,
|
|
532
|
+
};
|
|
533
|
+
let result: ReviewResult;
|
|
534
|
+
if (
|
|
535
|
+
early &&
|
|
536
|
+
!early.consumed &&
|
|
537
|
+
early.call.toolName === call.toolName &&
|
|
538
|
+
isDeepStrictEqual(early.call.input, call.input)
|
|
539
|
+
) {
|
|
540
|
+
early.consumed = true;
|
|
541
|
+
result = await early.result;
|
|
542
|
+
} else {
|
|
543
|
+
if (early) discard(early);
|
|
544
|
+
result = await review(ctx, config, call, policy.detail, ctx.signal);
|
|
545
|
+
}
|
|
546
|
+
return settle(ctx, config, call, result);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Pi emits `tool_result` only for calls that ran; a call another extension blocked has none.
|
|
550
|
+
pi.on("tool_result", (event, ctx) => {
|
|
551
|
+
const pending = pendingAudits.get(event.toolCallId);
|
|
552
|
+
if (!pending) return;
|
|
553
|
+
pendingAudits.delete(event.toolCallId);
|
|
554
|
+
streak = 0;
|
|
555
|
+
pending.entry.executed = true;
|
|
556
|
+
if (!isDeepStrictEqual(pending.approved, event.input)) {
|
|
557
|
+
pending.entry.argumentDrift = true;
|
|
558
|
+
notify(
|
|
559
|
+
ctx,
|
|
560
|
+
`Guardian: ${pending.entry.toolName} ran with arguments that changed after its review. An extension loaded after Guardian modified them; load Guardian last.`,
|
|
561
|
+
"warning",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
append(pending.entry);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
/** Record early reviews whose calls never consumed them. */
|
|
568
|
+
function discardPrefetches(): void {
|
|
569
|
+
for (const early of prefetched.values()) discard(early);
|
|
570
|
+
prefetched.clear();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function flush(): void {
|
|
574
|
+
discardPrefetches();
|
|
575
|
+
for (const { entry } of pendingAudits.values()) append({ ...entry, executed: false });
|
|
576
|
+
pendingAudits.clear();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
pi.on("turn_end", discardPrefetches);
|
|
580
|
+
pi.on("agent_end", () => {
|
|
581
|
+
flush();
|
|
582
|
+
calls.clear();
|
|
583
|
+
});
|
|
584
|
+
pi.on("before_agent_start", () => {
|
|
585
|
+
streak = 0;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
reviewing: () => [...reviewing.values()],
|
|
590
|
+
reset() {
|
|
591
|
+
for (const early of prefetched.values()) early.controller.abort();
|
|
592
|
+
prefetched.clear();
|
|
593
|
+
pendingAudits.clear();
|
|
594
|
+
calls.clear();
|
|
595
|
+
extensionInputs.length = 0;
|
|
596
|
+
streak = 0;
|
|
597
|
+
},
|
|
598
|
+
flush,
|
|
599
|
+
typedUserMessages() {
|
|
600
|
+
const session = host.session();
|
|
601
|
+
if (!session) return [];
|
|
602
|
+
return typedUserMessages({
|
|
603
|
+
sources: session.messages,
|
|
604
|
+
extensionMessages: extensionMessages(session.sessionManager.getBranch()),
|
|
605
|
+
});
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|