pi-warden 0.5.3 → 0.5.5
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/dist/done.d.ts +6 -1
- package/dist/done.js +28 -1
- package/dist/extension.d.ts +10 -1
- package/dist/extension.js +89 -23
- package/dist/guard.js +17 -0
- package/package.json +1 -1
package/dist/done.d.ts
CHANGED
|
@@ -5,7 +5,12 @@ export type ToolOutcome = "read" | "mutation" | "check-pass" | "check-fail" | "u
|
|
|
5
5
|
* What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
|
|
6
6
|
* (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
|
|
7
7
|
*/
|
|
8
|
-
export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean): ToolOutcome;
|
|
8
|
+
export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean, output?: string): ToolOutcome;
|
|
9
|
+
/**
|
|
10
|
+
* Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
|
|
11
|
+
* tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkSummary(output: string): "pass" | "fail" | undefined;
|
|
9
14
|
export interface RunEvidence {
|
|
10
15
|
mutations: number;
|
|
11
16
|
checks: Array<{
|
package/dist/done.js
CHANGED
|
@@ -9,7 +9,7 @@ const CHECK_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lin
|
|
|
9
9
|
* What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
|
|
10
10
|
* (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
|
|
11
11
|
*/
|
|
12
|
-
export function classifyToolResult(tool, input, failed) {
|
|
12
|
+
export function classifyToolResult(tool, input, failed, output) {
|
|
13
13
|
if (tool === "write" || tool === "edit")
|
|
14
14
|
return "mutation";
|
|
15
15
|
if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls")
|
|
@@ -19,8 +19,35 @@ export function classifyToolResult(tool, input, failed) {
|
|
|
19
19
|
return "unknown";
|
|
20
20
|
if (CHECK_COMMAND.test(view.command))
|
|
21
21
|
return failed ? "check-fail" : "check-pass";
|
|
22
|
+
// A test runner launched from inside a script (ctx_execute JavaScript, a Python wrapper) leaves no runner name in the
|
|
23
|
+
// command text, but its output still carries the runner's summary. Judge that summary instead.
|
|
24
|
+
const summary = output === undefined ? undefined : checkSummary(output);
|
|
25
|
+
if (summary)
|
|
26
|
+
return summary === "fail" || failed ? "check-fail" : "check-pass";
|
|
22
27
|
return view.shell && isReadOnlyCommand(view.command) ? "read" : "unknown";
|
|
23
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
|
|
31
|
+
* tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
|
|
32
|
+
*/
|
|
33
|
+
export function checkSummary(output) {
|
|
34
|
+
const tail = output.slice(-6000);
|
|
35
|
+
const nodeTest = /\u2139 (?:tests|pass|fail) \d+/.test(tail) && /\u2139 fail (\d+)/.exec(tail);
|
|
36
|
+
if (nodeTest)
|
|
37
|
+
return Number(nodeTest[1]) > 0 ? "fail" : "pass";
|
|
38
|
+
const jest = /^Tests:\s+(?:(\d+) failed, )?.*?\d+ total/m.exec(tail);
|
|
39
|
+
if (jest)
|
|
40
|
+
return jest[1] && Number(jest[1]) > 0 ? "fail" : "pass";
|
|
41
|
+
const pytest = /^=+ .*?(?:(\d+) failed|(\d+) error).*?in [\d.]+s/m.exec(tail) ?? /^=+ (\d+) passed.*? in [\d.]+s =+$/m.exec(tail);
|
|
42
|
+
if (pytest)
|
|
43
|
+
return /\d+ (?:failed|error)/.test(pytest[0]) ? "fail" : "pass";
|
|
44
|
+
const cargoOrGo = /^test result: (ok|FAILED)\./m.exec(tail) ?? /^(ok|FAIL)\s+\S+\s+[\d.]+s$/m.exec(tail);
|
|
45
|
+
if (cargoOrGo)
|
|
46
|
+
return cargoOrGo[1] === "ok" ? "pass" : "fail";
|
|
47
|
+
if (/\berror TS\d{4}:/.test(tail))
|
|
48
|
+
return "fail";
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
24
51
|
export function emptyEvidence() {
|
|
25
52
|
return { mutations: 0, checks: [] };
|
|
26
53
|
}
|
package/dist/extension.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SlopSymptom, Verdict } from "./guard.js";
|
|
3
3
|
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and tail compression. Compression stores an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
4
|
+
/**
|
|
5
|
+
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
6
|
+
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
7
|
+
*/
|
|
8
|
+
export declare function siblingToolCalls(ctx: ExtensionContext): Array<{
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
input: Record<string, unknown>;
|
|
12
|
+
}>;
|
|
4
13
|
export declare function confirmMessage(verdict: Verdict): string;
|
|
5
14
|
/** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
|
|
6
15
|
export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
|
package/dist/extension.js
CHANGED
|
@@ -53,6 +53,27 @@ function recentTaskContext(ctx) {
|
|
|
53
53
|
}
|
|
54
54
|
return messages.reverse();
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
58
|
+
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
59
|
+
*/
|
|
60
|
+
export function siblingToolCalls(ctx) {
|
|
61
|
+
const entries = ctx.sessionManager.getBranch();
|
|
62
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
63
|
+
const entry = entries[index];
|
|
64
|
+
if (entry?.type !== "message")
|
|
65
|
+
continue;
|
|
66
|
+
if (entry.message.role !== "assistant")
|
|
67
|
+
return [];
|
|
68
|
+
const content = entry.message.content;
|
|
69
|
+
if (!Array.isArray(content))
|
|
70
|
+
return [];
|
|
71
|
+
return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
|
|
72
|
+
? [{ id: part.id, name: part.name, input: (part.arguments ?? {}) }]
|
|
73
|
+
: []);
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
56
77
|
function activeMode(config, hasUI) {
|
|
57
78
|
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
58
79
|
const mode = isMode(env) ? env : config.mode;
|
|
@@ -76,7 +97,6 @@ export function confirmMessage(verdict) {
|
|
|
76
97
|
lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
|
|
77
98
|
return lines.join("\n");
|
|
78
99
|
}
|
|
79
|
-
const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
|
|
80
100
|
const SLOP_FIXES = {
|
|
81
101
|
stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
|
|
82
102
|
comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
|
|
@@ -99,8 +119,15 @@ export default function wardenExtension(pi) {
|
|
|
99
119
|
const trace = new Trace();
|
|
100
120
|
let panel;
|
|
101
121
|
let lastUi;
|
|
102
|
-
/**
|
|
103
|
-
const
|
|
122
|
+
/** Judgments for tool calls of the current assistant message, keyed by tool call id; `used` marks preflighted ones. */
|
|
123
|
+
const prejudged = new Map();
|
|
124
|
+
/**
|
|
125
|
+
* Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
|
|
126
|
+
* prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
|
|
127
|
+
* timeout), so approval is judged against the action itself, not matched against the earlier command text.
|
|
128
|
+
*/
|
|
129
|
+
let lastHoldPrompt;
|
|
130
|
+
let holdPending = false;
|
|
104
131
|
let attempts = new AttemptWindow(defaultConfig().stuck.window);
|
|
105
132
|
let evidence = emptyEvidence();
|
|
106
133
|
let doneNudged = false;
|
|
@@ -179,7 +206,8 @@ export default function wardenExtension(pi) {
|
|
|
179
206
|
widget.clear();
|
|
180
207
|
trace.clear();
|
|
181
208
|
panel?.close();
|
|
182
|
-
|
|
209
|
+
lastHoldPrompt = undefined;
|
|
210
|
+
holdPending = false;
|
|
183
211
|
attempts.reset();
|
|
184
212
|
evidence = emptyEvidence();
|
|
185
213
|
doneNudged = false;
|
|
@@ -194,6 +222,7 @@ export default function wardenExtension(pi) {
|
|
|
194
222
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
195
223
|
attempts = new AttemptWindow(configFor(ctx).stuck.window);
|
|
196
224
|
doneNudged = false;
|
|
225
|
+
prejudged.clear();
|
|
197
226
|
});
|
|
198
227
|
// Each low-level run collects its own evidence of changes and checks.
|
|
199
228
|
pi.on("agent_start", async () => {
|
|
@@ -202,6 +231,8 @@ export default function wardenExtension(pi) {
|
|
|
202
231
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
203
232
|
pi.on("turn_end", async () => {
|
|
204
233
|
ledger.turnEnd();
|
|
234
|
+
// Siblings that were never preflighted (an earlier one terminated the batch, or Esc) do not outlive their turn.
|
|
235
|
+
prejudged.clear();
|
|
205
236
|
});
|
|
206
237
|
pi.on("tool_call", async (event, ctx) => {
|
|
207
238
|
const config = configFor(ctx);
|
|
@@ -215,11 +246,29 @@ export default function wardenExtension(pi) {
|
|
|
215
246
|
return;
|
|
216
247
|
stats.inspected++;
|
|
217
248
|
const task = latestUserPrompt(ctx);
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const retryAfterHold = held.has(key) && heldAt !== task;
|
|
249
|
+
// A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
|
|
250
|
+
const retryAfterHold = holdPending && lastHoldPrompt !== task;
|
|
221
251
|
const judge = judgeFor(config);
|
|
222
|
-
const
|
|
252
|
+
const context = recentTaskContext(ctx);
|
|
253
|
+
const options = { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold };
|
|
254
|
+
const judgeCall = (tool, input) => evaluateAction({ tool, input, cwd: ctx.cwd, task, context }, options);
|
|
255
|
+
// Sibling calls in the same assistant message are preflighted one after another; their requests go out together.
|
|
256
|
+
// A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
|
|
257
|
+
if (judge && !retryAfterHold) {
|
|
258
|
+
for (const sibling of siblingToolCalls(ctx)) {
|
|
259
|
+
if (sibling.id === event.toolCallId || prejudged.has(sibling.id) || !config.action.tools.includes(sibling.name))
|
|
260
|
+
continue;
|
|
261
|
+
const verdict = judgeCall(sibling.name, sibling.input);
|
|
262
|
+
verdict.catch(() => undefined);
|
|
263
|
+
prejudged.set(sibling.id, { key: JSON.stringify(sibling.input), verdict, used: false });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// An earlier hook may have changed this call's input; a stale judgment is discarded, not reused.
|
|
267
|
+
const key = JSON.stringify(event.input);
|
|
268
|
+
const ready = prejudged.get(event.toolCallId);
|
|
269
|
+
const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(event.toolName, event.input);
|
|
270
|
+
prejudged.set(event.toolCallId, { key, verdict: pending, used: true });
|
|
271
|
+
const verdict = await pending;
|
|
223
272
|
if (verdict.source === "skipped")
|
|
224
273
|
return;
|
|
225
274
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
@@ -237,7 +286,7 @@ export default function wardenExtension(pi) {
|
|
|
237
286
|
}
|
|
238
287
|
if (verdict.approvedByUser) {
|
|
239
288
|
stats.approved++;
|
|
240
|
-
|
|
289
|
+
holdPending = false;
|
|
241
290
|
}
|
|
242
291
|
const mode = activeMode(config, ctx.hasUI);
|
|
243
292
|
const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
|
|
@@ -275,7 +324,10 @@ export default function wardenExtension(pi) {
|
|
|
275
324
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
276
325
|
}
|
|
277
326
|
stats.held++;
|
|
278
|
-
|
|
327
|
+
// A re-hold under the user's reply keeps the original reference prompt; otherwise the reply could never approve anything.
|
|
328
|
+
if (!holdPending)
|
|
329
|
+
lastHoldPrompt = task;
|
|
330
|
+
holdPending = true;
|
|
279
331
|
if (ctx.hasUI)
|
|
280
332
|
ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
|
|
281
333
|
return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
|
|
@@ -286,6 +338,17 @@ export default function wardenExtension(pi) {
|
|
|
286
338
|
return;
|
|
287
339
|
const textBlocks = event.content.filter(part => part.type === "text");
|
|
288
340
|
const text = textBlocks.map(part => part.text).join("\n");
|
|
341
|
+
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
342
|
+
const failed = resultFailed(event.isError, event.details, event.content);
|
|
343
|
+
const stuckCheck = (() => {
|
|
344
|
+
if (!config.stuck.enabled)
|
|
345
|
+
return undefined;
|
|
346
|
+
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
347
|
+
if (!attempts.shouldJudge(config.stuck))
|
|
348
|
+
return undefined;
|
|
349
|
+
stats.stuckChecks++;
|
|
350
|
+
return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
351
|
+
})();
|
|
289
352
|
const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
|
|
290
353
|
security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
|
|
291
354
|
signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
|
|
@@ -336,17 +399,12 @@ export default function wardenExtension(pi) {
|
|
|
336
399
|
]);
|
|
337
400
|
}
|
|
338
401
|
const patch = content === event.content ? undefined : { content };
|
|
339
|
-
// Checks
|
|
340
|
-
const failed = resultFailed(event.isError, event.details, event.content);
|
|
402
|
+
// Checks use the original result, not the excerpts or security banner.
|
|
341
403
|
if (config.done.enabled)
|
|
342
|
-
recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
|
|
343
|
-
|
|
404
|
+
recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
|
|
405
|
+
const verdict = await stuckCheck;
|
|
406
|
+
if (!verdict)
|
|
344
407
|
return patch;
|
|
345
|
-
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
346
|
-
if (!attempts.shouldJudge(config.stuck))
|
|
347
|
-
return patch;
|
|
348
|
-
stats.stuckChecks++;
|
|
349
|
-
const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
350
408
|
if (verdict.error)
|
|
351
409
|
noteError(ctx, verdict.error, undefined);
|
|
352
410
|
if (verdict.source === "repeat" && !verdict.stuck)
|
|
@@ -370,9 +428,17 @@ export default function wardenExtension(pi) {
|
|
|
370
428
|
const judge = judgeFor(config);
|
|
371
429
|
if (!finalMessage || !judge)
|
|
372
430
|
return;
|
|
373
|
-
|
|
431
|
+
// Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
|
|
432
|
+
const task = latestUserPrompt(ctx);
|
|
433
|
+
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
434
|
+
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
435
|
+
: undefined;
|
|
436
|
+
const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
|
|
437
|
+
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
438
|
+
: undefined;
|
|
439
|
+
if (proseCheck) {
|
|
374
440
|
stats.proseChecks++;
|
|
375
|
-
const verdict = await
|
|
441
|
+
const verdict = await proseCheck;
|
|
376
442
|
if (verdict.error)
|
|
377
443
|
noteError(ctx, verdict.error, undefined);
|
|
378
444
|
else
|
|
@@ -391,10 +457,10 @@ export default function wardenExtension(pi) {
|
|
|
391
457
|
steer(config, nudge, { deliverAs: "nextTurn" });
|
|
392
458
|
}
|
|
393
459
|
}
|
|
394
|
-
if (!
|
|
460
|
+
if (!doneCheck)
|
|
395
461
|
return;
|
|
396
462
|
stats.doneChecks++;
|
|
397
|
-
const verdict = await
|
|
463
|
+
const verdict = await doneCheck;
|
|
398
464
|
if (verdict.error)
|
|
399
465
|
noteError(ctx, verdict.error, undefined);
|
|
400
466
|
const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
|
package/dist/guard.js
CHANGED
|
@@ -287,7 +287,12 @@ export async function evaluateAction(action, options) {
|
|
|
287
287
|
const patterns = matchPatterns(action.tool, action.input, action.cwd);
|
|
288
288
|
const reasons = [];
|
|
289
289
|
let level = "allow";
|
|
290
|
+
// A shell command that merely mentions a secrets file (grep for key names, cat .env.example) is decided after Jev
|
|
291
|
+
// says whether it can write; write/edit on such a path, and offline runs, keep the immediate warning.
|
|
292
|
+
const deferSensitive = judge !== undefined && (action.tool !== "write" && action.tool !== "edit");
|
|
290
293
|
for (const hit of patterns) {
|
|
294
|
+
if (hit.severity === "sensitive" && deferSensitive)
|
|
295
|
+
continue;
|
|
291
296
|
level = higher(level, hit.severity === "destructive" ? "confirm" : "warn");
|
|
292
297
|
reasons.push(`${hit.severity}: ${hit.label}`);
|
|
293
298
|
}
|
|
@@ -323,6 +328,18 @@ export async function evaluateAction(action, options) {
|
|
|
323
328
|
judgment.approved = answers.approved.noul;
|
|
324
329
|
if (typeof answers.mutates?.noul === "number")
|
|
325
330
|
judgment.mutates = answers.mutates.noul;
|
|
331
|
+
if (deferSensitive) {
|
|
332
|
+
for (const hit of patterns) {
|
|
333
|
+
if (hit.severity !== "sensitive")
|
|
334
|
+
continue;
|
|
335
|
+
if ((judgment.mutates ?? 1) >= 0.5) {
|
|
336
|
+
level = higher(level, "warn");
|
|
337
|
+
reasons.push(`${hit.severity}: ${hit.label}`);
|
|
338
|
+
}
|
|
339
|
+
else
|
|
340
|
+
reasons.push(`${hit.label} (read-only, not warned)`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
326
343
|
// write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
|
|
327
344
|
const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
|
|
328
345
|
if (judgment.irreversible >= config.irreversible.confirm) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|