pi-warden 0.23.0 → 0.26.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/CHANGELOG.md +34 -0
- package/README.md +3 -1
- package/dist/arming.d.ts +60 -0
- package/dist/arming.js +190 -0
- package/dist/backend.d.ts +23 -0
- package/dist/backend.js +36 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +154 -2
- package/dist/extension.js +190 -17
- package/dist/extension.js.bak +1262 -0
- package/dist/guard.d.ts +31 -4
- package/dist/guard.js +274 -9
- package/dist/holds.d.ts +5 -1
- package/dist/holds.js +3 -1
- package/dist/learning.d.ts +68 -0
- package/dist/learning.js +190 -0
- package/dist/rules.js +1 -1
- package/dist/shape.js +14 -1
- package/dist/widget.js +1 -1
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { resolveBackend } from "./backend.js";
|
|
4
5
|
import { COMMAND_TOOLS } from "./tools.js";
|
|
5
6
|
import { defaultWidgetConfig } from "./widget.js";
|
|
6
7
|
const RECALL_TOOLS = ["auto", "rg", "ag", "ugrep", "git-grep", "grep", "select-string", "findstr", "none"];
|
|
@@ -15,6 +16,7 @@ export function defaultConfig() {
|
|
|
15
16
|
return {
|
|
16
17
|
enabled: true,
|
|
17
18
|
typesafe: false,
|
|
19
|
+
typesafeBackend: "typesafe",
|
|
18
20
|
mode: "steer",
|
|
19
21
|
timeoutMs: 5000,
|
|
20
22
|
maxRequests: 500,
|
|
@@ -28,6 +30,11 @@ export function defaultConfig() {
|
|
|
28
30
|
intentMismatch: 0.9,
|
|
29
31
|
visibleMismatch: 0.8,
|
|
30
32
|
feedbackLog: true,
|
|
33
|
+
commandRules: [],
|
|
34
|
+
commandDenyRules: [],
|
|
35
|
+
exemptRules: [],
|
|
36
|
+
pathRules: [],
|
|
37
|
+
armingRules: [],
|
|
31
38
|
},
|
|
32
39
|
stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
|
|
33
40
|
done: { enabled: true, claimsDone: 0.7, nudge: true },
|
|
@@ -94,7 +101,142 @@ function positiveInteger(value, fallback) {
|
|
|
94
101
|
export function isMode(value) {
|
|
95
102
|
return value === "steer" || value === "confirm" || value === "advise";
|
|
96
103
|
}
|
|
97
|
-
function
|
|
104
|
+
function parseCommandRule(raw, defaultSeverity) {
|
|
105
|
+
if (!isObject(raw))
|
|
106
|
+
return undefined;
|
|
107
|
+
const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
|
|
108
|
+
const pattern = typeof raw.pattern === "string" && raw.pattern.trim() ? raw.pattern : undefined;
|
|
109
|
+
if (!id || !pattern)
|
|
110
|
+
return undefined;
|
|
111
|
+
const severity = raw.severity === "warn" || raw.severity === "confirm" || raw.severity === "deny" ? raw.severity : defaultSeverity;
|
|
112
|
+
// Dialog is the default for a confirm rule (the reason one writes such a rule); hold restores steer semantics.
|
|
113
|
+
// A deny or warn rule cannot carry an action: there is nothing to prompt and nothing to steer differently, and a
|
|
114
|
+
// stray action on one would misreport in traces as a dialog rule.
|
|
115
|
+
const action = raw.action === "dialog" || raw.action === "hold" ? (severity === "confirm" ? raw.action : undefined) : severity === "confirm" ? "dialog" : undefined;
|
|
116
|
+
const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
|
|
117
|
+
const caseSensitive = typeof raw.caseSensitive === "boolean" ? raw.caseSensitive : false;
|
|
118
|
+
return { id, pattern, severity, ...(action ? { action } : {}), ...(message ? { message } : {}), ...(caseSensitive ? { caseSensitive } : {}) };
|
|
119
|
+
}
|
|
120
|
+
function parseCommandRules(raw, defaultSeverity) {
|
|
121
|
+
if (!Array.isArray(raw))
|
|
122
|
+
return [];
|
|
123
|
+
const ids = new Set();
|
|
124
|
+
const rules = [];
|
|
125
|
+
for (const item of raw) {
|
|
126
|
+
const rule = parseCommandRule(item, defaultSeverity);
|
|
127
|
+
if (!rule || ids.has(rule.id))
|
|
128
|
+
continue;
|
|
129
|
+
ids.add(rule.id);
|
|
130
|
+
rules.push(rule);
|
|
131
|
+
}
|
|
132
|
+
return rules;
|
|
133
|
+
}
|
|
134
|
+
function parseExemptRules(raw) {
|
|
135
|
+
if (!Array.isArray(raw))
|
|
136
|
+
return [];
|
|
137
|
+
const ids = new Set();
|
|
138
|
+
for (const item of raw) {
|
|
139
|
+
if (typeof item === "string" && item.trim())
|
|
140
|
+
ids.add(item.trim());
|
|
141
|
+
}
|
|
142
|
+
return [...ids];
|
|
143
|
+
}
|
|
144
|
+
const PATH_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "powershell", "ctx_execute", "ctx_batch_execute", "ctx_execute_file"]);
|
|
145
|
+
function parsePathRule(raw) {
|
|
146
|
+
if (!isObject(raw))
|
|
147
|
+
return undefined;
|
|
148
|
+
const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
|
|
149
|
+
const paths = globList(raw.paths, []);
|
|
150
|
+
if (!id || paths.length === 0)
|
|
151
|
+
return undefined;
|
|
152
|
+
// "*" widens the rule to the bash surface; other entries name the structured file-tool surfaces.
|
|
153
|
+
const requested = Array.isArray(raw.tools) ? raw.tools.filter((tool) => typeof tool === "string" && tool.trim().length > 0).map(tool => tool.trim()) : ["*"];
|
|
154
|
+
const tools = requested.length > 0 ? requested : ["*"];
|
|
155
|
+
const access = raw.access === "none" || raw.access === "read" || raw.access === "write" ? raw.access : "none";
|
|
156
|
+
const action = raw.action === "warn" || raw.action === "confirm" || raw.action === "block" ? raw.action : "note";
|
|
157
|
+
const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
|
|
158
|
+
const onlyIfExists = raw.onlyIfExists === false ? false : true;
|
|
159
|
+
return { id, paths, ...(raw.regex === true ? { regex: true } : {}), access, tools, action, ...(message ? { message } : {}), ...(onlyIfExists === false ? { onlyIfExists: false } : {}) };
|
|
160
|
+
}
|
|
161
|
+
function parsePathRules(raw) {
|
|
162
|
+
if (!Array.isArray(raw))
|
|
163
|
+
return [];
|
|
164
|
+
const ids = new Set();
|
|
165
|
+
const rules = [];
|
|
166
|
+
for (const item of raw) {
|
|
167
|
+
const rule = parsePathRule(item);
|
|
168
|
+
// A tool name that is neither a structured surface nor "*" is inert on every surface; keep it only when it means something.
|
|
169
|
+
if (!rule || ids.has(rule.id) || !rule.tools.some(tool => tool === "*" || PATH_TOOL_NAMES.has(tool)))
|
|
170
|
+
continue;
|
|
171
|
+
ids.add(rule.id);
|
|
172
|
+
rules.push(rule);
|
|
173
|
+
}
|
|
174
|
+
return rules;
|
|
175
|
+
}
|
|
176
|
+
/** Parse a duration string ("10m", "30s", "2h") or number (ms) into milliseconds. */
|
|
177
|
+
function parseDuration(raw, fallback) {
|
|
178
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0)
|
|
179
|
+
return raw;
|
|
180
|
+
if (typeof raw !== "string")
|
|
181
|
+
return fallback;
|
|
182
|
+
const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(raw.trim());
|
|
183
|
+
if (!match)
|
|
184
|
+
return fallback;
|
|
185
|
+
const value = parseFloat(match[1]);
|
|
186
|
+
const unit = match[2] ?? "m";
|
|
187
|
+
return value * (unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000);
|
|
188
|
+
}
|
|
189
|
+
const DEFAULT_ARMING_DURATION = 600_000; // 10 minutes
|
|
190
|
+
const ARMING_TOOLS = new Set(["write", "edit"]);
|
|
191
|
+
function parseArmingRule(raw) {
|
|
192
|
+
if (!isObject(raw))
|
|
193
|
+
return undefined;
|
|
194
|
+
const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
|
|
195
|
+
const whenRaw = isObject(raw.when) ? raw.when : undefined;
|
|
196
|
+
const edited = globList(whenRaw?.edited, []);
|
|
197
|
+
if (!id || edited.length === 0)
|
|
198
|
+
return undefined;
|
|
199
|
+
const armsRaw = isObject(raw.arms) ? raw.arms : {};
|
|
200
|
+
const command = typeof armsRaw.command === "string" && armsRaw.command.trim() ? armsRaw.command : undefined;
|
|
201
|
+
if (!command)
|
|
202
|
+
return undefined;
|
|
203
|
+
const action = raw.action === "confirm" || raw.action === "hold" || raw.action === "block" ? raw.action : undefined;
|
|
204
|
+
if (!action)
|
|
205
|
+
return undefined;
|
|
206
|
+
const toolsProvided = Array.isArray(whenRaw?.tools) && whenRaw.tools.length > 0;
|
|
207
|
+
const tools = toolsProvided
|
|
208
|
+
? whenRaw.tools.filter((t) => typeof t === "string" && ARMING_TOOLS.has(t))
|
|
209
|
+
: ["write", "edit"];
|
|
210
|
+
// A when.tools that filters to nothing (e.g. ["bash"]) is inert: no tool would ever arm it.
|
|
211
|
+
if (toolsProvided && tools.length === 0)
|
|
212
|
+
return undefined;
|
|
213
|
+
const forMs = parseDuration(armsRaw.for, DEFAULT_ARMING_DURATION);
|
|
214
|
+
const caseSensitive = typeof armsRaw.caseSensitive === "boolean" ? armsRaw.caseSensitive : false;
|
|
215
|
+
const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
|
|
216
|
+
const regex = whenRaw?.regex === true;
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
when: { edited, ...(regex ? { regex: true } : {}), ...(toolsProvided && tools.length > 0 ? { tools } : {}) },
|
|
220
|
+
arms: { command, for: forMs, ...(caseSensitive ? { caseSensitive } : {}) },
|
|
221
|
+
action,
|
|
222
|
+
...(message ? { message } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function parseArmingRules(raw) {
|
|
226
|
+
if (!Array.isArray(raw))
|
|
227
|
+
return [];
|
|
228
|
+
const ids = new Set();
|
|
229
|
+
const rules = [];
|
|
230
|
+
for (const item of raw) {
|
|
231
|
+
const rule = parseArmingRule(item);
|
|
232
|
+
if (!rule || ids.has(rule.id))
|
|
233
|
+
continue;
|
|
234
|
+
ids.add(rule.id);
|
|
235
|
+
rules.push(rule);
|
|
236
|
+
}
|
|
237
|
+
return rules;
|
|
238
|
+
}
|
|
239
|
+
function applyAction(base, raw, timeoutMs, source) {
|
|
98
240
|
const withTimeout = { ...base, timeoutMs };
|
|
99
241
|
if (!isObject(raw))
|
|
100
242
|
return withTimeout;
|
|
@@ -109,6 +251,15 @@ function applyAction(base, raw, timeoutMs) {
|
|
|
109
251
|
intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
|
|
110
252
|
visibleMismatch: Math.min(probability(raw.visibleMismatch, base.visibleMismatch), probability(raw.intentMismatch, base.intentMismatch)),
|
|
111
253
|
feedbackLog: boolean(raw.feedbackLog, base.feedbackLog),
|
|
254
|
+
// Only the user file declares these; a project file cannot add, edit, or remove them. The base (already the
|
|
255
|
+
// user's rules when a project file layers on top) is carried through, so a project "action" block cannot wipe them.
|
|
256
|
+
commandRules: source === "user" ? parseCommandRules(raw.commandRules, "warn") : base.commandRules,
|
|
257
|
+
commandDenyRules: source === "user" ? parseCommandRules(raw.commandDenyRules, "deny") : base.commandDenyRules,
|
|
258
|
+
exemptRules: source === "user" ? parseExemptRules(raw.exemptRules) : base.exemptRules,
|
|
259
|
+
// Path rules are user-declared security policy: a project file cannot add, edit, or remove them either.
|
|
260
|
+
pathRules: source === "user" ? parsePathRules(raw.pathRules) : base.pathRules,
|
|
261
|
+
// Arming rules are user-declared security policy: same gate.
|
|
262
|
+
armingRules: source === "user" ? parseArmingRules(raw.armingRules) : base.armingRules,
|
|
112
263
|
};
|
|
113
264
|
}
|
|
114
265
|
function applyStuck(base, raw) {
|
|
@@ -229,7 +380,7 @@ function applyGuards(base, raw, timeoutMs, source) {
|
|
|
229
380
|
subagent: applySubagent(base.subagent, raw.subagent),
|
|
230
381
|
// A project file may switch notifications off or on, but never names a command to run.
|
|
231
382
|
notify: applyNotify(base.notify, raw.notify, source === "user"),
|
|
232
|
-
action: applyAction(base.action, raw.action, timeoutMs),
|
|
383
|
+
action: applyAction(base.action, raw.action, timeoutMs, source),
|
|
233
384
|
stuck: applyStuck(base.stuck, raw.stuck),
|
|
234
385
|
done: applyDone(base.done, raw.done),
|
|
235
386
|
slop: applySlop(base.slop, raw.slop),
|
|
@@ -255,6 +406,7 @@ export function applyUserOverrides(base, raw) {
|
|
|
255
406
|
return {
|
|
256
407
|
enabled: boolean(raw.enabled, base.enabled),
|
|
257
408
|
typesafe: boolean(raw.typesafe, base.typesafe),
|
|
409
|
+
typesafeBackend: resolveBackend(raw.typesafeBackend),
|
|
258
410
|
mode: isMode(raw.mode) ? raw.mode : base.mode,
|
|
259
411
|
...shared,
|
|
260
412
|
...applyGuards(base, raw, shared.timeoutMs, "user"),
|
package/dist/extension.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as tuiModule from "@earendil-works/pi-tui";
|
|
2
|
+
const MouseRegion = tuiModule.MouseRegion;
|
|
2
3
|
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
3
4
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
5
|
+
import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
|
|
4
6
|
import { ActionGuard } from "./action-guard.js";
|
|
7
|
+
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
5
8
|
import * as configModule from "./config.js";
|
|
6
9
|
import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
7
|
-
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
|
|
8
|
-
import { evaluateAction, formatVerdict, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason } from "./guard.js";
|
|
10
|
+
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
|
|
11
|
+
import { evaluateAction, formatVerdict, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
12
|
+
import { commandOf } from "./tools.js";
|
|
9
13
|
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
14
|
+
import { initSchema, recordHold, recordOutcome, toHoldRecord } from "./learning.js";
|
|
10
15
|
import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
|
|
11
16
|
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice } from "./output.js";
|
|
12
17
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
@@ -191,9 +196,28 @@ export default function wardenExtension(pi) {
|
|
|
191
196
|
let panel;
|
|
192
197
|
let lastUi;
|
|
193
198
|
const actionGuard = new ActionGuard();
|
|
199
|
+
// Arming rules: session-scoped state that correlates preparation edits with later commands.
|
|
200
|
+
const arming = new ArmingTracker([]);
|
|
194
201
|
const rulesGuard = new RulesGuard();
|
|
195
202
|
// Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
|
|
196
203
|
const holds = new HoldLedger();
|
|
204
|
+
initSchema();
|
|
205
|
+
const learningIds = new Map(); // holds.id -> learning.id
|
|
206
|
+
// Prune learningIds when it grows large to prevent memory leaks
|
|
207
|
+
function pruneLearningIds() {
|
|
208
|
+
if (learningIds.size > 1000) {
|
|
209
|
+
const entries = [...learningIds.entries()];
|
|
210
|
+
learningIds.clear();
|
|
211
|
+
// Keep only the most recent 500
|
|
212
|
+
for (const [k, v] of entries.slice(-500))
|
|
213
|
+
learningIds.set(k, v);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function summarizeContext(context) {
|
|
217
|
+
if (!context?.length)
|
|
218
|
+
return undefined;
|
|
219
|
+
return context.slice(-8).map(m => m.role + ": " + redact(m.text.slice(0, 200))).join("\n");
|
|
220
|
+
}
|
|
197
221
|
const traceOf = new WeakMap();
|
|
198
222
|
let holdLog;
|
|
199
223
|
// Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
|
|
@@ -220,6 +244,10 @@ export default function wardenExtension(pi) {
|
|
|
220
244
|
let lastNotifiedAt = 0;
|
|
221
245
|
// A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
|
|
222
246
|
let shapeReported = false;
|
|
247
|
+
// An exemptRules id that names neither a built-in nor one of the user's own rules is inert; said once, not per call.
|
|
248
|
+
let exemptReported = false;
|
|
249
|
+
let inertReported = false;
|
|
250
|
+
let unparseableReported = false;
|
|
223
251
|
const configFor = (ctx) => {
|
|
224
252
|
const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
|
|
225
253
|
if (missing.length && !shapeReported) {
|
|
@@ -231,15 +259,46 @@ export default function wardenExtension(pi) {
|
|
|
231
259
|
else
|
|
232
260
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
233
261
|
}
|
|
262
|
+
const unknownExempt = unknownExemptIds(config.action.exemptRules, config.action.commandRules, config.action.commandDenyRules, config.action.pathRules, config.action.armingRules);
|
|
263
|
+
if (unknownExempt.length && !exemptReported) {
|
|
264
|
+
exemptReported = true;
|
|
265
|
+
const text = `warden: exemptRules names ${unknownExempt.join(", ")}, which match no built-in or user rule; those entries are inert`;
|
|
266
|
+
if (ctx.hasUI)
|
|
267
|
+
ctx.ui.notify(text, "warning");
|
|
268
|
+
else
|
|
269
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
270
|
+
}
|
|
271
|
+
const inert = inertPathRules(config.action.pathRules, config.action.tools);
|
|
272
|
+
if (inert.length && !inertReported) {
|
|
273
|
+
inertReported = true;
|
|
274
|
+
const text = `warden: path rules ${inert.join(", ")} can never fire with the current access/tools combination; check access polarity or add the tool to action.tools`;
|
|
275
|
+
if (ctx.hasUI)
|
|
276
|
+
ctx.ui.notify(text, "warning");
|
|
277
|
+
else
|
|
278
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
279
|
+
}
|
|
280
|
+
const unparseable = unparseableArmingRules(config.action.armingRules);
|
|
281
|
+
if (unparseable.length && !unparseableReported) {
|
|
282
|
+
// Separate flag so an operator with both an inert path rule and a bad arming regex sees both warnings.
|
|
283
|
+
unparseableReported = true;
|
|
284
|
+
const text = `warden: arming rules ${unparseable.join(", ")} have an invalid command regex; the rules will never fire`;
|
|
285
|
+
if (ctx.hasUI)
|
|
286
|
+
ctx.ui.notify(text, "warning");
|
|
287
|
+
else
|
|
288
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
|
|
289
|
+
}
|
|
290
|
+
arming.updateRules(config.action.armingRules);
|
|
234
291
|
return config;
|
|
235
292
|
};
|
|
236
293
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
237
294
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
238
|
-
/** A consent flag is not proof that judgments happen;
|
|
295
|
+
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
239
296
|
const judgeFor = (config) => {
|
|
240
|
-
if (!consentGiven(config) || budgetExhausted
|
|
297
|
+
if (!consentGiven(config) || budgetExhausted)
|
|
241
298
|
return undefined;
|
|
242
|
-
|
|
299
|
+
if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
|
|
300
|
+
return undefined;
|
|
301
|
+
return client ??= createTypeSafe(judgeOptions(config));
|
|
243
302
|
};
|
|
244
303
|
const noteError = (ctx, message, code) => {
|
|
245
304
|
stats.errors++;
|
|
@@ -271,12 +330,18 @@ export default function wardenExtension(pi) {
|
|
|
271
330
|
}
|
|
272
331
|
const entries = [...widget].map(([guard, line]) => ({ guard, line }));
|
|
273
332
|
// A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
333
|
+
// When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
|
|
334
|
+
// renders the same text, but a click does nothing. A missing named import is a link-time error
|
|
335
|
+
// that kills the entire extension silently; a missing namespace property is undefined.
|
|
336
|
+
ctx.ui.setWidget(WIDGET, (_tui, theme) => {
|
|
337
|
+
const body = statusWidget(entries, theme);
|
|
338
|
+
return MouseRegion ? new MouseRegion(body, event => {
|
|
339
|
+
if (event.type !== "click" || event.button !== "left")
|
|
340
|
+
return undefined;
|
|
341
|
+
togglePanel(lastUi, config);
|
|
342
|
+
return { handled: true };
|
|
343
|
+
}) : body;
|
|
344
|
+
}, { placement: config.widget.placement });
|
|
280
345
|
};
|
|
281
346
|
const record = (ctx, config, guard, line, details) => {
|
|
282
347
|
widget.set(guard, line);
|
|
@@ -399,6 +464,7 @@ export default function wardenExtension(pi) {
|
|
|
399
464
|
holds.reset();
|
|
400
465
|
regretCandidates = [];
|
|
401
466
|
holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
|
|
467
|
+
arming.reset();
|
|
402
468
|
attempts.reset();
|
|
403
469
|
evidence = emptyEvidence();
|
|
404
470
|
doneNudged = false;
|
|
@@ -495,6 +561,18 @@ export default function wardenExtension(pi) {
|
|
|
495
561
|
if (!config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
496
562
|
return;
|
|
497
563
|
stats.inspected++;
|
|
564
|
+
// Arming: a write/edit to a protected path arms matching command patterns for a window. Bash redirect/tee
|
|
565
|
+
// targets that match a when.edited glob also arm. This is session state, not a per-call verdict — it runs
|
|
566
|
+
// before the action guard so the armed check on a later command sees the preparation.
|
|
567
|
+
if (config.action.armingRules.length > 0) {
|
|
568
|
+
const inputPath = typeof event.input.path === "string" ? event.input.path : undefined;
|
|
569
|
+
const armCmd = commandOf(event.toolName, event.input)?.command;
|
|
570
|
+
// Parity with checkArmed: strip data text before extracting write-sink targets so a redirect
|
|
571
|
+
// mentioned inside a data heredoc (no shell sink) does not arm, but a real redirect in an executed
|
|
572
|
+
// heredoc or plain command does.
|
|
573
|
+
const sinks = armCmd ? writeSinkTargets(stripDataText(armCmd).text) : undefined;
|
|
574
|
+
arming.arm(event.toolName, inputPath, ctx.cwd, sinks);
|
|
575
|
+
}
|
|
498
576
|
const task = latestUserPrompt(ctx);
|
|
499
577
|
const judge = judgeFor(config);
|
|
500
578
|
const siblings = siblingToolCalls(ctx);
|
|
@@ -507,6 +585,51 @@ export default function wardenExtension(pi) {
|
|
|
507
585
|
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, previousActions: regretCandidates.length ? regretCandidates : undefined });
|
|
508
586
|
if (verdict.source === "skipped")
|
|
509
587
|
return;
|
|
588
|
+
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
589
|
+
// The hit is deterministic — it fires regardless of Jev. But to avoid spending a judge request on a call whose
|
|
590
|
+
// outcome is already decided by the armed hit, the armed check runs before the action guard when armed rules
|
|
591
|
+
// exist, and if it produces a deny/confirm/hold verdict, the judge is skipped entirely (fix 11).
|
|
592
|
+
const cmd = commandOf(event.toolName, event.input)?.command;
|
|
593
|
+
const armedHits = cmd && config.action.armingRules.length > 0 ? arming.checkArmed(cmd) : [];
|
|
594
|
+
// Fix 3: exemptRules can silence an armed rule by raw id (the injected id is `armed:<id>`, but the user wrote <id>).
|
|
595
|
+
const visibleArmedHits = armedHits.filter(hit => !config.action.exemptRules.includes(hit.id));
|
|
596
|
+
if (visibleArmedHits.length > 0) {
|
|
597
|
+
// Build the verdict from patterns alone — no judge request (fix 11). The deny/confirm/hold plumbing handles it.
|
|
598
|
+
const armedPatterns = [];
|
|
599
|
+
const armedReasons = [];
|
|
600
|
+
let armedLevel = "allow";
|
|
601
|
+
for (const hit of visibleArmedHits) {
|
|
602
|
+
const action = hit.action;
|
|
603
|
+
// Fix 1: hold → destructive severity + action:"hold" (steer-mode hold semantics, same as commandRules action:"hold");
|
|
604
|
+
// confirm → destructive + action:"dialog"; block → deny. The old code mapped hold to risky→warn, a no-op.
|
|
605
|
+
const severity = action === "block" ? "deny" : "destructive";
|
|
606
|
+
const patternHit = {
|
|
607
|
+
id: `armed:${hit.id}`,
|
|
608
|
+
severity,
|
|
609
|
+
label: hit.message ?? `armed rule ${hit.id}`,
|
|
610
|
+
...(action === "confirm" ? { action: "dialog" } : action === "hold" ? { action: "hold" } : {}),
|
|
611
|
+
...(hit.message ? { message: hit.message } : {}),
|
|
612
|
+
};
|
|
613
|
+
armedPatterns.push(patternHit);
|
|
614
|
+
// Fix 8: armed confirm dialog names the files that armed it.
|
|
615
|
+
const armedBy = hit.armedByPaths.length > 0 ? ` (armed by: ${hit.armedByPaths.join(", ")})` : "";
|
|
616
|
+
if (severity === "deny") {
|
|
617
|
+
armedLevel = "deny";
|
|
618
|
+
armedReasons.push(`${hit.message ?? `armed rule ${hit.id}`}${armedBy}`);
|
|
619
|
+
}
|
|
620
|
+
else if (severity === "destructive") {
|
|
621
|
+
armedLevel = higher(armedLevel, "confirm");
|
|
622
|
+
armedReasons.push(`armed: ${hit.message ?? hit.id}${armedBy}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Merge the armed patterns into the existing verdict (the action guard may have produced one too).
|
|
626
|
+
verdict.patterns.push(...armedPatterns);
|
|
627
|
+
verdict.level = higher(verdict.level, armedLevel);
|
|
628
|
+
verdict.reasons.push(...armedReasons);
|
|
629
|
+
// Fix 6: armed hits on read-only commands still record a trace line (bypass the read-only source gate).
|
|
630
|
+
if (verdict.source === "read-only")
|
|
631
|
+
verdict.source = "pattern";
|
|
632
|
+
}
|
|
510
633
|
if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
|
|
511
634
|
settleRegret(config, { regretted: regretsAt(verdict.judgment.regretted), target: verdict.judgment.regretTarget, probability: verdict.judgment.regretted, via: "jev" });
|
|
512
635
|
}
|
|
@@ -526,18 +649,42 @@ export default function wardenExtension(pi) {
|
|
|
526
649
|
if (verdict.approvedByUser) {
|
|
527
650
|
stats.approved++;
|
|
528
651
|
const released = holds.approved(event.toolName);
|
|
529
|
-
if (released)
|
|
652
|
+
if (released) {
|
|
530
653
|
noteOutcomes(config, [released]);
|
|
654
|
+
const learningId = learningIds.get(released.id);
|
|
655
|
+
if (learningId)
|
|
656
|
+
recordOutcome(learningId, "approved");
|
|
657
|
+
}
|
|
531
658
|
}
|
|
532
659
|
const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
|
|
660
|
+
// SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
|
|
661
|
+
// but the field is still one of the source values at runtime when no armed hits fired.
|
|
533
662
|
const entry = verdict.source !== "read-only" ? record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) })) : undefined;
|
|
534
663
|
// What happens to this call is the label for its scores; a decision made in the dialog lands at once, a steer-mode hold waits for the user.
|
|
535
664
|
const track = (held, outcome, via) => {
|
|
536
665
|
if (!entry)
|
|
537
666
|
return;
|
|
538
|
-
const
|
|
667
|
+
const recordOpts = { held, mode, outcome, via };
|
|
668
|
+
if (task)
|
|
669
|
+
recordOpts.task = task;
|
|
670
|
+
if (verdict.plan)
|
|
671
|
+
recordOpts.plan = verdict.plan;
|
|
672
|
+
const ctxSummary = summarizeContext(recentTaskContext(ctx));
|
|
673
|
+
if (ctxSummary)
|
|
674
|
+
recordOpts.contextSummary = ctxSummary;
|
|
675
|
+
if (held)
|
|
676
|
+
recordOpts.agentReason = steerReason(verdict, { canApprove: judge !== undefined });
|
|
677
|
+
const item = holds.record(verdict, recordOpts);
|
|
539
678
|
traceOf.set(item, entry);
|
|
540
679
|
noteOutcomes(config, outcome ? [item] : []);
|
|
680
|
+
// Record to SQLite for learning
|
|
681
|
+
if (held) {
|
|
682
|
+
const id = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) }));
|
|
683
|
+
learningIds.set(item.id, id);
|
|
684
|
+
if (outcome)
|
|
685
|
+
recordOutcome(id, outcome);
|
|
686
|
+
pruneLearningIds();
|
|
687
|
+
}
|
|
541
688
|
};
|
|
542
689
|
// The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
|
|
543
690
|
if (verdict.intentMismatch) {
|
|
@@ -608,11 +755,34 @@ export default function wardenExtension(pi) {
|
|
|
608
755
|
track(false);
|
|
609
756
|
return undefined;
|
|
610
757
|
}
|
|
758
|
+
if (verdict.level === "deny") {
|
|
759
|
+
stats.held++;
|
|
760
|
+
track(true, "declined", "deny");
|
|
761
|
+
if (ctx.hasUI && config.notices)
|
|
762
|
+
ctx.ui.notify(`warden · blocked ${event.toolName}: ${verdict.reasons.join("; ")}`, "error");
|
|
763
|
+
notifyDesktop(ctx, config, `Blocked ${event.toolName}: ${verdict.reasons.join("; ")}. A deny rule matched; the call never ran.`);
|
|
764
|
+
return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${verdict.reasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
|
|
765
|
+
}
|
|
611
766
|
if (verdict.level !== "confirm") {
|
|
612
767
|
track(false);
|
|
613
768
|
return undefined;
|
|
614
769
|
}
|
|
615
770
|
const reasons = verdict.reasons.join("; ");
|
|
771
|
+
// A user-defined confirm rule prompts the user in every mode, advise included: the operator wrote the rule to be
|
|
772
|
+
// asked. Advise mode keeps Jev holds advisory; it does not soften a prompt the operator asked for by name.
|
|
773
|
+
// The action defaults to dialog at parse time (the reason one writes such a rule); hold restores steer semantics.
|
|
774
|
+
const dialogRule = verdict.patterns.some(hit => hit.action === "dialog");
|
|
775
|
+
if (dialogRule && ctx.hasUI) {
|
|
776
|
+
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
777
|
+
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
778
|
+
if (allowed) {
|
|
779
|
+
track(true, "approved", "dialog");
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
stats.held++;
|
|
783
|
+
track(true, "declined", "dialog");
|
|
784
|
+
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.` };
|
|
785
|
+
}
|
|
616
786
|
if (mode === "advise") {
|
|
617
787
|
stats.warned++;
|
|
618
788
|
if (ctx.hasUI && config.notices)
|
|
@@ -836,7 +1006,7 @@ export default function wardenExtension(pi) {
|
|
|
836
1006
|
const patch = content === event.content ? undefined : { content };
|
|
837
1007
|
// Checks use the original result, not the excerpts or security banner.
|
|
838
1008
|
if (config.done.enabled)
|
|
839
|
-
|
|
1009
|
+
recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
|
|
840
1010
|
const verdict = await stuckCheck;
|
|
841
1011
|
if (!verdict)
|
|
842
1012
|
return patch;
|
|
@@ -863,6 +1033,8 @@ export default function wardenExtension(pi) {
|
|
|
863
1033
|
const config = configFor(ctx);
|
|
864
1034
|
if (!config.enabled)
|
|
865
1035
|
return;
|
|
1036
|
+
// Arming state survives across runs within a session (the Scenario B case: edit in one turn,
|
|
1037
|
+
// reconcile in the next). It is cleared on session_start and bounded by the rule's `for` window.
|
|
866
1038
|
// No guarded call carried the regret question this run (the agent only replied): the offline heuristic reads the prompt.
|
|
867
1039
|
if (regretCandidates.length)
|
|
868
1040
|
settleRegret(config, { regretted: textRegrets(latestUserPrompt(ctx)), via: "text" });
|
|
@@ -976,13 +1148,14 @@ export default function wardenExtension(pi) {
|
|
|
976
1148
|
const usage = client?.getUsage();
|
|
977
1149
|
const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
|
|
978
1150
|
report([
|
|
979
|
-
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}; ${auth.text}`,
|
|
1151
|
+
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
|
|
980
1152
|
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
|
|
981
1153
|
formatSteers(stats),
|
|
982
1154
|
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
|
|
983
1155
|
formatLedger(ledger.snapshot()),
|
|
984
1156
|
`${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
|
|
985
1157
|
`Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
|
|
1158
|
+
...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
|
|
986
1159
|
`Desktop notifications: ${config.notify.enabled ? `on (${config.notify.command.length ? `command ${config.notify.command[0]}` : (await (notifier ??= detectNotifier())) ?? "no notifier found on this machine"}; cooldown ${config.notify.cooldownMs} ms)` : "off (\"notify\": { \"enabled\": true } in the config turns them on)"}.`,
|
|
987
1160
|
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
988
1161
|
widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
|
|
@@ -1062,7 +1235,7 @@ export default function wardenExtension(pi) {
|
|
|
1062
1235
|
}
|
|
1063
1236
|
if (action === "test") {
|
|
1064
1237
|
const judge = judgeFor(config);
|
|
1065
|
-
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to
|
|
1238
|
+
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
|
|
1066
1239
|
return;
|
|
1067
1240
|
const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
|
|
1068
1241
|
record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));
|