pi-warden 0.47.0 → 0.48.1
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 +31 -0
- package/README.md +23 -3
- package/dist/action-guard.d.ts +2 -0
- package/dist/action-guard.js +1 -1
- package/dist/extension.d.ts +9 -3
- package/dist/extension.js +78 -18
- package/dist/extension.js.bak +263 -76
- package/dist/guard.d.ts +17 -0
- package/dist/guard.js +59 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,37 @@ How to keep this current: add the entry in the same pull request as the change,
|
|
|
8
8
|
|
|
9
9
|
<!-- Empty. Next release starts here. -->
|
|
10
10
|
|
|
11
|
+
## 0.48.1
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The intent-mismatch question is asked only when the agent stated a plan for the call: the text of the assistant message that makes the call, or of the text-only message right before it with no tool call in between. Before this, a call with no text of its own was judged against the latest assistant text since the user's prompt, often written before several earlier calls. A shell command with a visible effect (a `git` commit, push, merge, tag, or reset, `gh pr`, `gh release`, or `npm publish` in any segment, decided in code before the request) is still judged against the latest assistant text since the prompt, because an unannounced commit or push after an older plan is the mismatch users object to. On the recorded sessions from 2026-09-21 to 2026-09-24, 151 of 163 intent-mismatch notices were judged against stale text; the question would now be asked for 63 of the 163 (8 against the call's own text, 55 visible actions against stale text). A skipped question is one fewer question in the action request.
|
|
16
|
+
|
|
17
|
+
### Tooling
|
|
18
|
+
|
|
19
|
+
- `tests/steer-delivery.test.ts` pins that a steer sent from `tool_call` or `tool_result` for the last call of a turn joins the request that carries the tool result and costs no extra model request, so the intent-mismatch notice stays a steer.
|
|
20
|
+
- `scripts/intent-cases.mjs` reads Pi session logs and, for every intent-mismatch notice, classifies where the plan came from: the message that made the call, the text-only message right before it, or text from before earlier tool calls (stale). It also runs the built `assistantPlan` on each call's branch and counts the notices whose question would still be asked. Aggregate counts only; offline.
|
|
21
|
+
|
|
22
|
+
### Docs
|
|
23
|
+
|
|
24
|
+
- README leads with a new image of the self-correction loop and its numbers from the first nine days of use; the headline numbers are updated to match.
|
|
25
|
+
|
|
26
|
+
## 0.48.0
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `PI_WARDEN_HOST_PATHS`: a `:`-separated list of absolute directories outside the project where the host lets its agent write. A `write` or `edit` in one of them, after `..` and symlinks are resolved, is not held or warned by the outside-project rule; command rules, path rules, deny rules, sensitive paths, secrets, and Jev still apply. Relative entries, empty entries, and `/` are ignored. Environment only; no config file can set it.
|
|
31
|
+
|
|
32
|
+
## 0.47.1
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- A done-check nudge no longer clears the evidence that caused it. A run that a warden follow-up starts (done-check nudge, runaway recovery, subagent wake) keeps the changes and checks of the run before it, so the agent must still show a passing check. A later "done" with no check in that run is judged and recorded as unverified; the one-nudge-per-prompt bound stays. A user prompt still starts with empty evidence.
|
|
37
|
+
|
|
38
|
+
### Docs
|
|
39
|
+
|
|
40
|
+
- Field usage report for 2026-09-21 to 2026-09-24 (`eval/reports/2026-09-24-field-usage/`) and `scripts/field-usage.mjs`, which reads Pi session logs and the holds database and prints aggregate counts only. README leads with the field numbers, names the task spine under Privacy, and notes the scratch-delete exemption and the judgments-off notice.
|
|
41
|
+
|
|
11
42
|
## 0.47.0
|
|
12
43
|
|
|
13
44
|
### Changed
|
package/README.md
CHANGED
|
@@ -6,7 +6,11 @@ pi-warden supervises Pi while it works, catching risky actions, ignored rules, s
|
|
|
6
6
|
|
|
7
7
|
Instead of interrupting you for every problem, Warden usually feeds the issue back to the agent so it can correct itself and keep going.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**In nine days of real use: 743 sessions, 193 risky actions held before they ran, 1,292 notes sent to the agent. After a "done" with no test behind it, the agent ran one 78% of the time.**
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
<sub>Numbers from the first nine days of real use (2026-09-16 to 2026-09-24, 743 sessions). The quoted lines are verbatim. Counted with `scripts/field-usage.mjs`; the [field report](eval/reports/2026-09-24-field-usage/) has the method and what was noise.</sub>
|
|
10
14
|
|
|
11
15
|
## Install
|
|
12
16
|
|
|
@@ -39,8 +43,10 @@ Works without any key (offline guards: pattern list, runaway stop, sensitive-pat
|
|
|
39
43
|
|
|
40
44
|
## How intervention works
|
|
41
45
|
|
|
46
|
+

|
|
47
|
+
|
|
42
48
|
1. **Read-only? Skip.** `git status`, `ls`, `read` means no request and no trace entry.
|
|
43
|
-
2. **Known-dangerous pattern? Catch it locally.** Force push, `git reset --hard`, recursive `rm`, SQL `DROP` held instantly.
|
|
49
|
+
2. **Known-dangerous pattern? Catch it locally.** Force push, `git reset --hard`, recursive `rm`, SQL `DROP` held instantly. An agent deleting temp-directory scratch it created in the same session is not held (macOS and Windows).
|
|
44
50
|
3. **Needs judgment?** Warden evaluates the action in context.
|
|
45
51
|
4. **Steer or hold.** Most issues go back to the agent so it can correct itself. Irreversible actions can be stopped before they run.
|
|
46
52
|
|
|
@@ -52,6 +58,7 @@ In the default `steer` mode, Warden talks to the agent rather than interrupting
|
|
|
52
58
|
- **Custom policies**: command, path, and arming rules for your own workflow.
|
|
53
59
|
- **Context saver** trims oversized tool output while keeping the full result retrievable.
|
|
54
60
|
- **Multiple judgment backends**: TypeSafe by default, with OpenRouter support.
|
|
61
|
+
- **Honest about being off**: when judgments cannot run (no consent, no key, a rejected key, budget spent) Warden says so once, with the fix. A failing backend is paused after repeated errors instead of costing a timeout per action.
|
|
55
62
|
- **Full traceability**: inspect what Warden saw, decided, and told the agent.
|
|
56
63
|
- **Desktop alerts** for events that need you.
|
|
57
64
|
|
|
@@ -72,6 +79,19 @@ Jev judges every write and quotes violations back. Rules can be things no linter
|
|
|
72
79
|
|
|
73
80
|
## Does it actually help?
|
|
74
81
|
|
|
82
|
+
### In daily use
|
|
83
|
+
|
|
84
|
+
Four days of the maintainer's own work, 397 sessions ([report](eval/reports/2026-09-24-field-usage/), [raw numbers](eval/reports/2026-09-24-field-usage/usage.json), `scripts/field-usage.mjs` to run it on your own logs):
|
|
85
|
+
|
|
86
|
+
- **Done-check:** 47 nudges; in 35 the agent then ran a test or build.
|
|
87
|
+
- **Holds:** 143 of 19,695 actions. Of the 44 with a recorded outcome, the agent took a safer route 33 times.
|
|
88
|
+
- **Rules:** 195 corrections naming a project rule, including a process wait with no timeout and a home path in a public test fixture.
|
|
89
|
+
- **Context:** about 123,000 tokens of oversized output trimmed in the traced sessions, and no agent needed the full output back.
|
|
90
|
+
|
|
91
|
+
The report also lists what was noise that week (scratch deletes, credential notices on source code, two over-broad rules) and what changed because of it.
|
|
92
|
+
|
|
93
|
+
### In benchmarks
|
|
94
|
+
|
|
75
95
|
In 150 paired agent runs, the control setup violated the tested project rule **6 times**. With Warden: **0**.
|
|
76
96
|
|
|
77
97
|
An overnight stability run covered **13,952 guard cases across 109 cycles** with no score drift.
|
|
@@ -86,7 +106,7 @@ These are project-maintained benchmarks, not universal claims. Raw reports and r
|
|
|
86
106
|
|
|
87
107
|
## Privacy
|
|
88
108
|
|
|
89
|
-
Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md) Security work — pentesting, incident response, CTF — has its own configuration recipe for keeping samples local and for the friction that credentials and lab setups cause: [Recipe: security work](docs/configuration.md#recipe-security-work).
|
|
109
|
+
Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, the task spine (your first request in the thread and up to four earlier ones, capped at 1,200 characters), and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md) Security work — pentesting, incident response, CTF — has its own configuration recipe for keeping samples local and for the friction that credentials and lab setups cause: [Recipe: security work](docs/configuration.md#recipe-security-work).
|
|
90
110
|
|
|
91
111
|
## Documentation
|
|
92
112
|
|
package/dist/action-guard.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface InspectOptions {
|
|
|
36
36
|
previousActions?: readonly PreviousAction[] | undefined;
|
|
37
37
|
/** Real paths the agent created under the temp directory in this session, forwarded to `evaluateAction`. */
|
|
38
38
|
scratch?: ScratchRecords | undefined;
|
|
39
|
+
/** Real paths of host directories, forwarded to `evaluateAction`. */
|
|
40
|
+
hostPaths?: readonly string[] | undefined;
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
43
|
* The Action guard for one session. `evaluateAction` judges a single call; this module owns what spans calls:
|
package/dist/action-guard.js
CHANGED
|
@@ -20,7 +20,7 @@ export class ActionGuard {
|
|
|
20
20
|
const { task } = conversation;
|
|
21
21
|
// A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
|
|
22
22
|
const retryAfterHold = this.holdPending && this.lastHoldPrompt !== task;
|
|
23
|
-
const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan, spine: conversation.spine }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, largeOutput: options.largeOutput, rules: options.rules, retryAfterHold, previousActions, scratch: options.scratch });
|
|
23
|
+
const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan, spine: conversation.spine }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, largeOutput: options.largeOutput, rules: options.rules, retryAfterHold, previousActions, scratch: options.scratch, hostPaths: options.hostPaths });
|
|
24
24
|
// A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
|
|
25
25
|
if (options.judge && !retryAfterHold) {
|
|
26
26
|
for (const sibling of conversation.siblings ?? []) {
|
package/dist/extension.d.ts
CHANGED
|
@@ -20,11 +20,17 @@ export declare function formatSteers(stats: {
|
|
|
20
20
|
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
21
21
|
*/
|
|
22
22
|
export declare function siblingToolCalls(ctx: ExtensionContext): ToolCallRef[];
|
|
23
|
+
/** Whether the call is a shell command with a visible effect (commit, push, merge, tag, reset, pull request, release, publish). */
|
|
24
|
+
export declare function isVisibleAction(tool: string, input: Record<string, unknown>): boolean;
|
|
23
25
|
/**
|
|
24
|
-
* The agent's own words
|
|
25
|
-
* tool calls only, the
|
|
26
|
+
* The agent's own words for the call: the text of the assistant message that carries it, or, when that message is
|
|
27
|
+
* tool calls only, the text of the assistant message right before it with no tool call in between. Text from before an
|
|
28
|
+
* earlier tool call described that call, so it is no plan and the intent question is not asked. The exception is
|
|
29
|
+
* `visibleAction` (a commit, push, merge, tag, reset, pull request, release, or publish, by `isVisibleCommand`): such a
|
|
30
|
+
* call is still judged against the latest assistant text since the user's prompt, because an unannounced commit or push
|
|
31
|
+
* after an older plan is the mismatch users object to. Sent as `plan`; it explains the step and cannot approve it.
|
|
26
32
|
*/
|
|
27
|
-
export declare function assistantPlan(ctx: ExtensionContext): string | undefined;
|
|
33
|
+
export declare function assistantPlan(ctx: ExtensionContext, visibleAction?: boolean): string | undefined;
|
|
28
34
|
/**
|
|
29
35
|
* The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
|
|
30
36
|
* variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
|
package/dist/extension.js
CHANGED
|
@@ -12,7 +12,7 @@ import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
|
12
12
|
import * as configModule from "./config.js";
|
|
13
13
|
import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
14
14
|
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
|
|
15
|
-
import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
15
|
+
import { createdScratch, evaluateAction, formatVerdictTokens, higher, hostPaths, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets, isVisibleCommand } from "./guard.js";
|
|
16
16
|
import { commandOf } from "./tools.js";
|
|
17
17
|
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
18
18
|
import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
|
|
@@ -113,12 +113,24 @@ export function siblingToolCalls(ctx) {
|
|
|
113
113
|
}
|
|
114
114
|
return [];
|
|
115
115
|
}
|
|
116
|
+
/** Whether the call is a shell command with a visible effect (commit, push, merge, tag, reset, pull request, release, publish). */
|
|
117
|
+
export function isVisibleAction(tool, input) {
|
|
118
|
+
const view = commandOf(tool, input);
|
|
119
|
+
return view?.shell === true && isVisibleCommand(view.command);
|
|
120
|
+
}
|
|
116
121
|
/**
|
|
117
|
-
* The agent's own words
|
|
118
|
-
* tool calls only, the
|
|
122
|
+
* The agent's own words for the call: the text of the assistant message that carries it, or, when that message is
|
|
123
|
+
* tool calls only, the text of the assistant message right before it with no tool call in between. Text from before an
|
|
124
|
+
* earlier tool call described that call, so it is no plan and the intent question is not asked. The exception is
|
|
125
|
+
* `visibleAction` (a commit, push, merge, tag, reset, pull request, release, or publish, by `isVisibleCommand`): such a
|
|
126
|
+
* call is still judged against the latest assistant text since the user's prompt, because an unannounced commit or push
|
|
127
|
+
* after an older plan is the mismatch users object to. Sent as `plan`; it explains the step and cannot approve it.
|
|
119
128
|
*/
|
|
120
|
-
export function assistantPlan(ctx) {
|
|
129
|
+
export function assistantPlan(ctx, visibleAction = false) {
|
|
121
130
|
const entries = ctx.sessionManager.getBranch();
|
|
131
|
+
const plan = planForCall(entries);
|
|
132
|
+
if (plan !== undefined || !visibleAction)
|
|
133
|
+
return plan;
|
|
122
134
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
123
135
|
const entry = entries[index];
|
|
124
136
|
if (entry?.type !== "message")
|
|
@@ -127,10 +139,44 @@ export function assistantPlan(ctx) {
|
|
|
127
139
|
return undefined;
|
|
128
140
|
if (entry.message.role !== "assistant")
|
|
129
141
|
continue;
|
|
142
|
+
const text = assistantText(entry.message.content);
|
|
143
|
+
if (text)
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
function assistantText(content) {
|
|
149
|
+
return (typeof content === "string" ? content : content.filter((part) => part.type === "text").map(part => part.text).join("\n")).trim();
|
|
150
|
+
}
|
|
151
|
+
/** The text of the message that carries the call, or of the assistant message right before it with no tool call in between. */
|
|
152
|
+
function planForCall(entries) {
|
|
153
|
+
let carrierSeen = false;
|
|
154
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
155
|
+
const entry = entries[index];
|
|
156
|
+
if (entry?.type !== "message")
|
|
157
|
+
continue;
|
|
158
|
+
const { role } = entry.message;
|
|
159
|
+
if (role === "user")
|
|
160
|
+
return undefined;
|
|
161
|
+
// A result after the carrier belongs to a sibling call; one before it means an earlier tool call ran.
|
|
162
|
+
if (role === "toolResult") {
|
|
163
|
+
if (carrierSeen)
|
|
164
|
+
return undefined;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (role !== "assistant")
|
|
168
|
+
continue;
|
|
130
169
|
const content = entry.message.content;
|
|
131
|
-
const text =
|
|
132
|
-
if (
|
|
133
|
-
|
|
170
|
+
const text = assistantText(content);
|
|
171
|
+
if (!carrierSeen) {
|
|
172
|
+
if (text)
|
|
173
|
+
return text;
|
|
174
|
+
carrierSeen = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (typeof content !== "string" && content.some(part => part.type === "toolCall"))
|
|
178
|
+
return undefined;
|
|
179
|
+
return text || undefined;
|
|
134
180
|
}
|
|
135
181
|
return undefined;
|
|
136
182
|
}
|
|
@@ -261,6 +307,8 @@ export default function wardenExtension(pi) {
|
|
|
261
307
|
// The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
|
|
262
308
|
let traceFile;
|
|
263
309
|
let unsubscribeTraceFile;
|
|
310
|
+
// Directories outside the project the host lets its agent write; set per session from PI_WARDEN_HOST_PATHS.
|
|
311
|
+
let sessionHostPaths = [];
|
|
264
312
|
let panel;
|
|
265
313
|
let configPanel;
|
|
266
314
|
let lastUi;
|
|
@@ -295,6 +343,8 @@ export default function wardenExtension(pi) {
|
|
|
295
343
|
let fullOutputs = new Map();
|
|
296
344
|
let evidence = emptyEvidence();
|
|
297
345
|
let doneNudged = false;
|
|
346
|
+
/** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
|
|
347
|
+
let wardenContinuation = false;
|
|
298
348
|
let warnedFallback = false;
|
|
299
349
|
let warnedMissingRules = false;
|
|
300
350
|
/** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
|
|
@@ -621,9 +671,10 @@ export default function wardenExtension(pi) {
|
|
|
621
671
|
stats.steerGuards[name] = (stats.steerGuards[name] ?? 0) + 1;
|
|
622
672
|
const critical = names.every(name => CRITICAL_STEER_GUARDS.has(name));
|
|
623
673
|
// A notice delivered once is already in the agent's context. Sending the repeat again costs the accounting turn it
|
|
624
|
-
// forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget:
|
|
625
|
-
//
|
|
626
|
-
//
|
|
674
|
+
// forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: a steer sent during
|
|
675
|
+
// a tool call rides the request the tool result needs (tests/steer-delivery.test.ts), but one queued after the run
|
|
676
|
+
// ends starts a new model turn, and every steer asks the agent for a reply sentence; a closing run that collects six
|
|
677
|
+
// notices collects six restatements of the final status. A notice skipped for the budget keeps its fingerprint, so the same notice can deliver next run.
|
|
627
678
|
// Critical guards (stuck, done, runaway recovery, subagent wake) always deliver: their message starts the turn.
|
|
628
679
|
const overBudget = config.steerBudget > 0 && steersThisRun >= config.steerBudget;
|
|
629
680
|
const deliver = critical || (!overBudget && !steerRepeats.seen(content));
|
|
@@ -635,6 +686,8 @@ export default function wardenExtension(pi) {
|
|
|
635
686
|
steersThisRun++;
|
|
636
687
|
const { display, ...delivery } = options ?? { deliverAs: "steer" };
|
|
637
688
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
|
|
689
|
+
if (delivery.triggerTurn)
|
|
690
|
+
wardenContinuation = true;
|
|
638
691
|
return true;
|
|
639
692
|
};
|
|
640
693
|
/**
|
|
@@ -727,6 +780,7 @@ export default function wardenExtension(pi) {
|
|
|
727
780
|
ctx.ui.notify(text, "warning");
|
|
728
781
|
else
|
|
729
782
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
783
|
+
sessionHostPaths = hostPaths();
|
|
730
784
|
const dir = traceDir();
|
|
731
785
|
if (dir) {
|
|
732
786
|
const warn = (text) => { if (ctx.hasUI)
|
|
@@ -741,6 +795,7 @@ export default function wardenExtension(pi) {
|
|
|
741
795
|
attempts.reset();
|
|
742
796
|
evidence = emptyEvidence();
|
|
743
797
|
doneNudged = false;
|
|
798
|
+
wardenContinuation = false;
|
|
744
799
|
prose.reset();
|
|
745
800
|
// Clean up temp output dirs from the previous session.
|
|
746
801
|
for (const dir of ledger.storedPaths()) {
|
|
@@ -798,6 +853,7 @@ export default function wardenExtension(pi) {
|
|
|
798
853
|
attempts = new AttemptWindow(config.stuck.window);
|
|
799
854
|
fullOutputs = new Map();
|
|
800
855
|
doneNudged = false;
|
|
856
|
+
wardenContinuation = false;
|
|
801
857
|
steersThisRun = 0;
|
|
802
858
|
finals.reset();
|
|
803
859
|
runaway.reset();
|
|
@@ -1090,9 +1146,12 @@ export default function wardenExtension(pi) {
|
|
|
1090
1146
|
conscienceGeneration++;
|
|
1091
1147
|
ctx.abort();
|
|
1092
1148
|
});
|
|
1093
|
-
// Each low-level run collects its own evidence of changes and checks.
|
|
1149
|
+
// Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
|
|
1150
|
+
// the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
|
|
1094
1151
|
pi.on("agent_start", async () => {
|
|
1095
|
-
|
|
1152
|
+
if (!wardenContinuation)
|
|
1153
|
+
evidence = emptyEvidence();
|
|
1154
|
+
wardenContinuation = false;
|
|
1096
1155
|
});
|
|
1097
1156
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
1098
1157
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -1220,7 +1279,7 @@ export default function wardenExtension(pi) {
|
|
|
1220
1279
|
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1221
1280
|
: undefined;
|
|
1222
1281
|
rulesCheck?.catch(() => undefined);
|
|
1223
|
-
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch });
|
|
1282
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx, isVisibleAction(event.toolName, event.input)), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch, hostPaths: sessionHostPaths });
|
|
1224
1283
|
if (verdict.source === "skipped")
|
|
1225
1284
|
return;
|
|
1226
1285
|
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
@@ -1701,9 +1760,9 @@ export default function wardenExtension(pi) {
|
|
|
1701
1760
|
}
|
|
1702
1761
|
if (key && !earlier)
|
|
1703
1762
|
ledger.remember(key, event.toolName, storedPath);
|
|
1704
|
-
// The banner in the result already tells the agent
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1763
|
+
// The banner in the result already tells the agent, so the notice rides the result content alone and is not also
|
|
1764
|
+
// steered. A steer sent here would join the request that carries this result and cost no extra turn
|
|
1765
|
+
// (tests/steer-delivery.test.ts); it would only repeat the banner.
|
|
1707
1766
|
if (notice && textBlocks.length) {
|
|
1708
1767
|
if (!bannersPlacedWithBlocks) {
|
|
1709
1768
|
let index = 0;
|
|
@@ -1945,7 +2004,7 @@ export default function wardenExtension(pi) {
|
|
|
1945
2004
|
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
1946
2005
|
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1947
2006
|
: undefined;
|
|
1948
|
-
const doneCheck = config.done.enabled &&
|
|
2007
|
+
const doneCheck = config.done.enabled && needsDoneCheck(evidence)
|
|
1949
2008
|
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1950
2009
|
: undefined;
|
|
1951
2010
|
if (proseCheck) {
|
|
@@ -1975,7 +2034,8 @@ export default function wardenExtension(pi) {
|
|
|
1975
2034
|
const verdict = await doneCheck;
|
|
1976
2035
|
if (verdict.error)
|
|
1977
2036
|
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1978
|
-
|
|
2037
|
+
// At most one nudge per user prompt; a later unverified claim is still judged and recorded.
|
|
2038
|
+
const nudge = verdict.unverified && config.done.nudge && !doneNudged ? doneNudge(verdict) : undefined;
|
|
1979
2039
|
record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
|
|
1980
2040
|
if (!verdict.unverified)
|
|
1981
2041
|
return;
|
package/dist/extension.js.bak
CHANGED
|
@@ -6,13 +6,13 @@ import * as tuiModule from "@earendil-works/pi-tui";
|
|
|
6
6
|
const MouseRegion = tuiModule.MouseRegion;
|
|
7
7
|
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
8
8
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
9
|
-
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
|
|
9
|
+
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
|
|
10
10
|
import { ActionGuard } from "./action-guard.js";
|
|
11
11
|
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
12
12
|
import * as configModule from "./config.js";
|
|
13
13
|
import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
14
14
|
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
|
|
15
|
-
import { evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
15
|
+
import { createdScratch, evaluateAction, formatVerdictTokens, higher, hostPaths, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
16
16
|
import { commandOf } from "./tools.js";
|
|
17
17
|
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
18
18
|
import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
|
|
@@ -20,7 +20,7 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
|
|
|
20
20
|
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
|
|
21
21
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
22
22
|
import { redact } from "./redact.js";
|
|
23
|
-
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
|
|
23
|
+
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
|
|
24
24
|
import { checkPiWardenMissing } from "./rules-file.js";
|
|
25
25
|
import { writeStarterRules, buildInitPrompt } from "./init.js";
|
|
26
26
|
import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
|
|
@@ -31,39 +31,19 @@ import { assess } from "./conscience.js";
|
|
|
31
31
|
import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
|
|
32
32
|
import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
|
|
33
33
|
import { fileContentHash } from "./hashing.js";
|
|
34
|
-
|
|
35
|
-
function classifyConscienceError(err) {
|
|
36
|
-
if (err && typeof err === "object" && "code" in err) {
|
|
37
|
-
const code = err.code;
|
|
38
|
-
if (code === "timeout")
|
|
39
|
-
return "timeout";
|
|
40
|
-
if (code === "http" || code === "connection" || code === "response")
|
|
41
|
-
return "network";
|
|
42
|
-
if (code === "configuration" || code === "validation")
|
|
43
|
-
return "configuration";
|
|
44
|
-
if (code === "budget" || code === "aborted")
|
|
45
|
-
return "other";
|
|
46
|
-
}
|
|
47
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
-
if (/timeout|timed out/i.test(msg))
|
|
49
|
-
return "timeout";
|
|
50
|
-
if (/auth|key|credential|401|403/i.test(msg))
|
|
51
|
-
return "auth";
|
|
52
|
-
if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
|
|
53
|
-
return "network";
|
|
54
|
-
return "other";
|
|
55
|
-
}
|
|
34
|
+
import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-cooldown.js";
|
|
56
35
|
import { openConfigPanel, openTracePanel } from "./panel.js";
|
|
57
|
-
import { completeConfig, shapeWarning } from "./shape.js";
|
|
36
|
+
import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
|
|
58
37
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
59
38
|
import { buildCompactSnapshot, compactAppendix } from "./compact.js";
|
|
60
39
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
61
40
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
41
|
+
import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
|
|
62
42
|
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
|
|
63
|
-
export 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, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
43
|
+
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
64
44
|
const WIDGET = PACKAGE_NAME;
|
|
65
45
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
66
|
-
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
46
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
67
47
|
/**
|
|
68
48
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
69
49
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
@@ -154,6 +134,20 @@ export function assistantPlan(ctx) {
|
|
|
154
134
|
}
|
|
155
135
|
return undefined;
|
|
156
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
|
|
139
|
+
* variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
|
|
140
|
+
* names a key value or a path.
|
|
141
|
+
*/
|
|
142
|
+
export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
|
|
143
|
+
switch (reason) {
|
|
144
|
+
case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
|
|
145
|
+
case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
|
|
146
|
+
case "key_rejected": return rejectedEnv
|
|
147
|
+
? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
|
|
148
|
+
: "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
157
151
|
function activeMode(config, hasUI) {
|
|
158
152
|
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
159
153
|
const mode = isMode(env) ? env : config.mode;
|
|
@@ -256,10 +250,19 @@ export default function wardenExtension(pi) {
|
|
|
256
250
|
// fail-closes (no delivery) until the policy is re-measured.
|
|
257
251
|
const consciencePolicy = CONSCIENCE_BETA_POLICY;
|
|
258
252
|
let client;
|
|
253
|
+
const cooldown = new JudgeCooldown();
|
|
254
|
+
let judgeConfig;
|
|
255
|
+
/** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
|
|
256
|
+
let noticeUi;
|
|
259
257
|
let budgetExhausted = false;
|
|
260
258
|
let stats = freshStats();
|
|
261
259
|
const widget = new Map();
|
|
262
260
|
const trace = new Trace();
|
|
261
|
+
// The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
|
|
262
|
+
let traceFile;
|
|
263
|
+
let unsubscribeTraceFile;
|
|
264
|
+
// Directories outside the project the host lets its agent write; set per session from PI_WARDEN_HOST_PATHS.
|
|
265
|
+
let sessionHostPaths = [];
|
|
263
266
|
let panel;
|
|
264
267
|
let configPanel;
|
|
265
268
|
let lastUi;
|
|
@@ -294,6 +297,8 @@ export default function wardenExtension(pi) {
|
|
|
294
297
|
let fullOutputs = new Map();
|
|
295
298
|
let evidence = emptyEvidence();
|
|
296
299
|
let doneNudged = false;
|
|
300
|
+
/** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
|
|
301
|
+
let wardenContinuation = false;
|
|
297
302
|
let warnedFallback = false;
|
|
298
303
|
let warnedMissingRules = false;
|
|
299
304
|
/** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
|
|
@@ -309,6 +314,13 @@ export default function wardenExtension(pi) {
|
|
|
309
314
|
const secretsSeen = new Set();
|
|
310
315
|
// Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
|
|
311
316
|
const subagentSeen = new Set();
|
|
317
|
+
// Command families already steered toward filtered output this session: one steer per family.
|
|
318
|
+
const largeOutputSteered = new Set();
|
|
319
|
+
// Real paths the agent created under the temp directory this session, with the identity each had when recorded.
|
|
320
|
+
// A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
|
|
321
|
+
const sessionScratch = new Map();
|
|
322
|
+
// Per tool call id: when it started and the temp paths it may create that did not exist yet.
|
|
323
|
+
const scratchPending = new Map();
|
|
312
324
|
const wakePolicy = new WakePolicy(0);
|
|
313
325
|
const runaway = new RunawayMonitor();
|
|
314
326
|
// Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
|
|
@@ -368,14 +380,79 @@ export default function wardenExtension(pi) {
|
|
|
368
380
|
return config;
|
|
369
381
|
};
|
|
370
382
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
383
|
+
/** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
|
|
384
|
+
const judgmentsOffReason = (config) => {
|
|
385
|
+
if (!consentGiven(config))
|
|
386
|
+
return "no_consent";
|
|
387
|
+
if (budgetExhausted)
|
|
388
|
+
return "budget";
|
|
389
|
+
const auth = authState({ backend: config.typesafeBackend });
|
|
390
|
+
if (auth.usable)
|
|
391
|
+
return undefined;
|
|
392
|
+
// No key in effect, or one whose last request came back 401 or 403.
|
|
393
|
+
return auth.source === undefined ? "no_key" : "key_rejected";
|
|
394
|
+
};
|
|
395
|
+
// Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
|
|
396
|
+
let judgmentsNotify;
|
|
397
|
+
let judgmentsHeadless = false;
|
|
398
|
+
const judgmentsReported = new Set();
|
|
399
|
+
/** Say once per session and reason that judgments are off, and record the state in the trace file. */
|
|
400
|
+
const noteJudgments = (config, reason) => {
|
|
401
|
+
traceFile?.judgments(judgmentsState(reason));
|
|
402
|
+
// The budget has its own notice in noteError.
|
|
403
|
+
if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
|
|
404
|
+
return;
|
|
405
|
+
judgmentsReported.add(reason);
|
|
406
|
+
const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
|
|
407
|
+
judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
|
|
408
|
+
};
|
|
371
409
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
372
410
|
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
373
411
|
const judgeFor = (config) => {
|
|
374
|
-
|
|
412
|
+
const off = judgmentsOffReason(config);
|
|
413
|
+
noteJudgments(config, off);
|
|
414
|
+
if (off)
|
|
375
415
|
return undefined;
|
|
376
|
-
|
|
416
|
+
judgeConfig = config;
|
|
417
|
+
// Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
|
|
418
|
+
if (cooldown.active()) {
|
|
419
|
+
stats.cooldownSkips++;
|
|
377
420
|
return undefined;
|
|
378
|
-
|
|
421
|
+
}
|
|
422
|
+
return client ??= watched(createTypeSafe(judgeOptions(config)));
|
|
423
|
+
};
|
|
424
|
+
/** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
|
|
425
|
+
const watched = (typesafe) => new Proxy(typesafe, {
|
|
426
|
+
get(target, prop, receiver) {
|
|
427
|
+
if (prop !== "evaluate")
|
|
428
|
+
return Reflect.get(target, prop, receiver);
|
|
429
|
+
return async (...args) => {
|
|
430
|
+
try {
|
|
431
|
+
const result = await target.evaluate(...args);
|
|
432
|
+
tellCooldown(cooldown.success());
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
const kind = cooldownFailureKind(err, args[1]?.signal);
|
|
437
|
+
if (kind)
|
|
438
|
+
tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
const tellCooldown = (event) => {
|
|
445
|
+
if (!event || !noticeUi)
|
|
446
|
+
return;
|
|
447
|
+
if (event.type === "recovered") {
|
|
448
|
+
noticeUi.notify("warden: judgments resumed.", "info");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const seconds = Math.round(event.ms / 1000);
|
|
452
|
+
const remedy = event.kind === "auth"
|
|
453
|
+
? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
|
|
454
|
+
: event.kind === "configuration" ? " /warden status shows the backend setup." : "";
|
|
455
|
+
noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
|
|
379
456
|
};
|
|
380
457
|
const noteError = (ctx, message, code) => {
|
|
381
458
|
stats.errors++;
|
|
@@ -387,15 +464,22 @@ export default function wardenExtension(pi) {
|
|
|
387
464
|
/** Click, shortcut, and /warden trace all toggle the same sidebar. */
|
|
388
465
|
const togglePanel = (ui, config) => {
|
|
389
466
|
if (!ui)
|
|
390
|
-
return;
|
|
467
|
+
return undefined;
|
|
391
468
|
if (panel) {
|
|
392
469
|
panel.close();
|
|
393
|
-
return;
|
|
470
|
+
return undefined;
|
|
394
471
|
}
|
|
395
472
|
const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
|
|
396
473
|
panel = opened;
|
|
397
474
|
opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
|
|
398
475
|
panel = undefined; });
|
|
476
|
+
return opened;
|
|
477
|
+
};
|
|
478
|
+
/** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
|
|
479
|
+
const traceText = () => {
|
|
480
|
+
const entries = trace.entries();
|
|
481
|
+
const text = entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.";
|
|
482
|
+
return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
|
|
399
483
|
};
|
|
400
484
|
/** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
|
|
401
485
|
const toggleConfigPanel = (ui, config) => {
|
|
@@ -555,6 +639,8 @@ export default function wardenExtension(pi) {
|
|
|
555
639
|
steersThisRun++;
|
|
556
640
|
const { display, ...delivery } = options ?? { deliverAs: "steer" };
|
|
557
641
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
|
|
642
|
+
if (delivery.triggerTurn)
|
|
643
|
+
wardenContinuation = true;
|
|
558
644
|
return true;
|
|
559
645
|
};
|
|
560
646
|
/**
|
|
@@ -617,6 +703,8 @@ export default function wardenExtension(pi) {
|
|
|
617
703
|
pi.on("session_start", async (_event, ctx) => {
|
|
618
704
|
if (ctx.hasUI)
|
|
619
705
|
lastUi = ctx.ui;
|
|
706
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
707
|
+
cooldown.reset();
|
|
620
708
|
if (_event.reason === "reload" || _event.reason === "new")
|
|
621
709
|
conscienceGeneration++;
|
|
622
710
|
client = undefined;
|
|
@@ -634,11 +722,33 @@ export default function wardenExtension(pi) {
|
|
|
634
722
|
rulesGuard.reset();
|
|
635
723
|
holds.reset();
|
|
636
724
|
regretCandidates = [];
|
|
637
|
-
|
|
725
|
+
const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
|
|
726
|
+
holdLog = new HoldLog(holdLogPath(sessionId));
|
|
727
|
+
unsubscribeTraceFile?.();
|
|
728
|
+
unsubscribeTraceFile = undefined;
|
|
729
|
+
traceFile = undefined;
|
|
730
|
+
judgmentsReported.clear();
|
|
731
|
+
judgmentsHeadless = !ctx.hasUI;
|
|
732
|
+
judgmentsNotify = text => { if (ctx.hasUI)
|
|
733
|
+
ctx.ui.notify(text, "warning");
|
|
734
|
+
else
|
|
735
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
736
|
+
sessionHostPaths = hostPaths();
|
|
737
|
+
const dir = traceDir();
|
|
738
|
+
if (dir) {
|
|
739
|
+
const warn = (text) => { if (ctx.hasUI)
|
|
740
|
+
ctx.ui.notify(text, "warning");
|
|
741
|
+
else
|
|
742
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
743
|
+
const opening = loadConfig();
|
|
744
|
+
traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
|
|
745
|
+
unsubscribeTraceFile = trace.subscribe(traceFile.listener);
|
|
746
|
+
}
|
|
638
747
|
arming.reset();
|
|
639
748
|
attempts.reset();
|
|
640
749
|
evidence = emptyEvidence();
|
|
641
750
|
doneNudged = false;
|
|
751
|
+
wardenContinuation = false;
|
|
642
752
|
prose.reset();
|
|
643
753
|
// Clean up temp output dirs from the previous session.
|
|
644
754
|
for (const dir of ledger.storedPaths()) {
|
|
@@ -653,6 +763,9 @@ export default function wardenExtension(pi) {
|
|
|
653
763
|
compressionLearner.reset();
|
|
654
764
|
secretsSeen.clear();
|
|
655
765
|
subagentSeen.clear();
|
|
766
|
+
largeOutputSteered.clear();
|
|
767
|
+
sessionScratch.clear();
|
|
768
|
+
scratchPending.clear();
|
|
656
769
|
wakePolicy.reset();
|
|
657
770
|
steerRepeats.reset();
|
|
658
771
|
steersThisRun = 0;
|
|
@@ -688,10 +801,12 @@ export default function wardenExtension(pi) {
|
|
|
688
801
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
689
802
|
if (ctx.hasUI)
|
|
690
803
|
lastUi = ctx.ui;
|
|
804
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
691
805
|
const config = configFor(ctx);
|
|
692
806
|
attempts = new AttemptWindow(config.stuck.window);
|
|
693
807
|
fullOutputs = new Map();
|
|
694
808
|
doneNudged = false;
|
|
809
|
+
wardenContinuation = false;
|
|
695
810
|
steersThisRun = 0;
|
|
696
811
|
finals.reset();
|
|
697
812
|
runaway.reset();
|
|
@@ -744,6 +859,7 @@ export default function wardenExtension(pi) {
|
|
|
744
859
|
const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
|
|
745
860
|
// Build recent context from session branch (up to 4 messages, 500 chars each)
|
|
746
861
|
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
862
|
+
const spine = taskSpine(branch, event.prompt);
|
|
747
863
|
const recentMessages = [];
|
|
748
864
|
for (const entry of branch.slice(-6)) {
|
|
749
865
|
if (recentMessages.length >= 4)
|
|
@@ -804,7 +920,7 @@ export default function wardenExtension(pi) {
|
|
|
804
920
|
// it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
|
|
805
921
|
let judgeModel = "";
|
|
806
922
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
|
|
807
|
-
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
923
|
+
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, judgmentsOff: judgmentsOffReason(config), config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
808
924
|
// Check generation after await
|
|
809
925
|
if (conscienceGeneration !== myGeneration) {
|
|
810
926
|
record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
|
|
@@ -889,7 +1005,7 @@ export default function wardenExtension(pi) {
|
|
|
889
1005
|
}
|
|
890
1006
|
}
|
|
891
1007
|
catch (err) {
|
|
892
|
-
const category =
|
|
1008
|
+
const category = classifyJudgeError(err);
|
|
893
1009
|
if (!warnedErrorCategories.has(category)) {
|
|
894
1010
|
warnedErrorCategories.add(category);
|
|
895
1011
|
console.warn(`pi-warden: conscience ${category}`);
|
|
@@ -925,7 +1041,10 @@ export default function wardenExtension(pi) {
|
|
|
925
1041
|
const judge = judgeFor(config);
|
|
926
1042
|
if (judge && cachedSkills.length > 0) {
|
|
927
1043
|
const myGeneration = conscienceGeneration;
|
|
928
|
-
const
|
|
1044
|
+
const prompt = typeof event.message.content === "string" ? event.message.content : "";
|
|
1045
|
+
const redactedText = redact(prompt).slice(0, 2000);
|
|
1046
|
+
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
1047
|
+
const spine = taskSpine(branch, prompt);
|
|
929
1048
|
let toolInfos = [];
|
|
930
1049
|
try {
|
|
931
1050
|
toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
|
|
@@ -936,13 +1055,13 @@ export default function wardenExtension(pi) {
|
|
|
936
1055
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
937
1056
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
938
1057
|
try {
|
|
939
|
-
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1058
|
+
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
940
1059
|
if (conscienceGeneration !== myGeneration)
|
|
941
1060
|
return;
|
|
942
1061
|
record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.skipReason ?? "none"})`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
943
1062
|
}
|
|
944
1063
|
catch (err) {
|
|
945
|
-
const cat =
|
|
1064
|
+
const cat = classifyJudgeError(err);
|
|
946
1065
|
if (!warnedErrorCategories.has(cat)) {
|
|
947
1066
|
warnedErrorCategories.add(cat);
|
|
948
1067
|
console.warn(`pi-warden: conscience ${cat}`);
|
|
@@ -980,9 +1099,12 @@ export default function wardenExtension(pi) {
|
|
|
980
1099
|
conscienceGeneration++;
|
|
981
1100
|
ctx.abort();
|
|
982
1101
|
});
|
|
983
|
-
// Each low-level run collects its own evidence of changes and checks.
|
|
1102
|
+
// Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
|
|
1103
|
+
// the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
|
|
984
1104
|
pi.on("agent_start", async () => {
|
|
985
|
-
|
|
1105
|
+
if (!wardenContinuation)
|
|
1106
|
+
evidence = emptyEvidence();
|
|
1107
|
+
wardenContinuation = false;
|
|
986
1108
|
});
|
|
987
1109
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
988
1110
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -1002,11 +1124,13 @@ export default function wardenExtension(pi) {
|
|
|
1002
1124
|
catch {
|
|
1003
1125
|
toolInfos = [];
|
|
1004
1126
|
}
|
|
1005
|
-
const
|
|
1127
|
+
const prompt = latestUserPrompt(ctx);
|
|
1128
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1129
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1006
1130
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1007
1131
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1008
1132
|
try {
|
|
1009
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1133
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1010
1134
|
if (conscienceGeneration !== myGeneration)
|
|
1011
1135
|
return;
|
|
1012
1136
|
assessmentsThisPrompt++;
|
|
@@ -1031,6 +1155,8 @@ export default function wardenExtension(pi) {
|
|
|
1031
1155
|
const config = configFor(ctx);
|
|
1032
1156
|
if (!config.enabled)
|
|
1033
1157
|
return;
|
|
1158
|
+
if (event.toolName === "bash" || event.toolName === "write")
|
|
1159
|
+
scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
|
|
1034
1160
|
// ── Conscience: track tool attempts on the selected capability ──
|
|
1035
1161
|
if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
|
|
1036
1162
|
if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
|
|
@@ -1060,14 +1186,17 @@ export default function wardenExtension(pi) {
|
|
|
1060
1186
|
if (!config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
1061
1187
|
return;
|
|
1062
1188
|
stats.inspected++;
|
|
1063
|
-
// First-run warning:
|
|
1189
|
+
// First-run warning: no project rules file resolved, one time per session.
|
|
1064
1190
|
if (!warnedMissingRules && config.rules.enabled) {
|
|
1065
|
-
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
|
|
1191
|
+
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
|
|
1066
1192
|
if (missing && ctx.hasUI) {
|
|
1067
1193
|
warnedMissingRules = true;
|
|
1194
|
+
// The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
|
|
1195
|
+
// turned the fallback off would send them to a document pi-warden will not read.
|
|
1196
|
+
const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
|
|
1068
1197
|
const msg = fallbackSource
|
|
1069
|
-
? `No pi-warden.md
|
|
1070
|
-
: `No rules file detected (
|
|
1198
|
+
? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
|
|
1199
|
+
: `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
|
|
1071
1200
|
ctx.ui.notify(msg, "warning");
|
|
1072
1201
|
}
|
|
1073
1202
|
}
|
|
@@ -1103,7 +1232,7 @@ export default function wardenExtension(pi) {
|
|
|
1103
1232
|
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1104
1233
|
: undefined;
|
|
1105
1234
|
rulesCheck?.catch(() => undefined);
|
|
1106
|
-
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, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
|
|
1235
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch, hostPaths: sessionHostPaths });
|
|
1107
1236
|
if (verdict.source === "skipped")
|
|
1108
1237
|
return;
|
|
1109
1238
|
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
@@ -1159,6 +1288,11 @@ export default function wardenExtension(pi) {
|
|
|
1159
1288
|
// Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
|
|
1160
1289
|
const notes = [];
|
|
1161
1290
|
const noteGuards = new Set();
|
|
1291
|
+
/** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
|
|
1292
|
+
* never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
|
|
1293
|
+
* and the sensitive-path notes, the order of the combined message. */
|
|
1294
|
+
const contentNotes = [];
|
|
1295
|
+
const pathNotes = [];
|
|
1162
1296
|
/** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
|
|
1163
1297
|
const pathNoteTraces = [];
|
|
1164
1298
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
@@ -1223,15 +1357,24 @@ export default function wardenExtension(pi) {
|
|
|
1223
1357
|
noteGuards.add("action");
|
|
1224
1358
|
notes.push(shouldProceedMessage(verdict));
|
|
1225
1359
|
}
|
|
1360
|
+
const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
|
|
1361
|
+
if (largeOutput) {
|
|
1362
|
+
noteGuards.add("action");
|
|
1363
|
+
notes.push(largeOutput);
|
|
1364
|
+
}
|
|
1226
1365
|
if (verdict.slopSymptoms?.length && verdict.slopReasons) {
|
|
1227
1366
|
stats.slop++;
|
|
1228
|
-
|
|
1229
|
-
for (const symptom of verdict.slopSymptoms)
|
|
1230
|
-
slopCounts[symptom]++;
|
|
1367
|
+
const symptoms = verdict.slopSymptoms;
|
|
1231
1368
|
const where = verdict.summary.path ?? event.toolName;
|
|
1232
1369
|
if (ctx.hasUI && config.notices)
|
|
1233
1370
|
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
1234
|
-
|
|
1371
|
+
contentNotes.push(proceeds => {
|
|
1372
|
+
if (!proceeds)
|
|
1373
|
+
return undefined;
|
|
1374
|
+
for (const symptom of symptoms)
|
|
1375
|
+
slopCounts[symptom]++;
|
|
1376
|
+
return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
|
|
1377
|
+
});
|
|
1235
1378
|
}
|
|
1236
1379
|
if (rulesCheck) {
|
|
1237
1380
|
const rules = await rulesCheck;
|
|
@@ -1239,16 +1382,23 @@ export default function wardenExtension(pi) {
|
|
|
1239
1382
|
stats.ruleChecks++;
|
|
1240
1383
|
if (rules.error)
|
|
1241
1384
|
noteError(ctx, rules.error, rules.errorCode);
|
|
1242
|
-
const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1243
|
-
record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
|
|
1244
1385
|
holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
|
|
1245
|
-
if (
|
|
1386
|
+
if (rules.findings.length) {
|
|
1246
1387
|
stats.ruleViolations++;
|
|
1247
|
-
noteGuards.add("rules");
|
|
1248
1388
|
if (ctx.hasUI && config.notices)
|
|
1249
1389
|
ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
1250
|
-
notes.push(told);
|
|
1251
1390
|
}
|
|
1391
|
+
contentNotes.push(proceeds => {
|
|
1392
|
+
const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1393
|
+
const details = rulesDetails(rules, told);
|
|
1394
|
+
if (!proceeds && rules.findings.length)
|
|
1395
|
+
details.push("agent not told: the write was held");
|
|
1396
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
|
|
1397
|
+
return told ? { guard: "rules", text: told } : undefined;
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
|
|
1252
1402
|
}
|
|
1253
1403
|
const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
|
|
1254
1404
|
if (hits.length && verdict.summary.path) {
|
|
@@ -1262,17 +1412,29 @@ export default function wardenExtension(pi) {
|
|
|
1262
1412
|
ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
|
|
1263
1413
|
});
|
|
1264
1414
|
noteGuards.add("rules");
|
|
1265
|
-
|
|
1415
|
+
pathNotes.push(told);
|
|
1266
1416
|
}
|
|
1267
1417
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
for (const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1418
|
+
/** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
|
|
1419
|
+
const deliverNotes = (proceeds) => {
|
|
1420
|
+
for (const contentNote of contentNotes) {
|
|
1421
|
+
const note = contentNote(proceeds);
|
|
1422
|
+
if (!note)
|
|
1423
|
+
continue;
|
|
1424
|
+
noteGuards.add(note.guard);
|
|
1425
|
+
notes.push(note.text);
|
|
1426
|
+
}
|
|
1427
|
+
notes.push(...pathNotes);
|
|
1428
|
+
if (notes.length) {
|
|
1429
|
+
const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
|
|
1430
|
+
for (const traceNote of pathNoteTraces)
|
|
1431
|
+
traceNote(delivered);
|
|
1432
|
+
pathNoteTraces.length = 0;
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1274
1435
|
const warnSteer = (reasons) => reasons.length > 0 && steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
|
|
1275
1436
|
if (verdict.level === "warn") {
|
|
1437
|
+
deliverNotes(true);
|
|
1276
1438
|
stats.warned++;
|
|
1277
1439
|
if (ctx.hasUI && config.notices)
|
|
1278
1440
|
ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
|
|
@@ -1284,6 +1446,7 @@ export default function wardenExtension(pi) {
|
|
|
1284
1446
|
return undefined;
|
|
1285
1447
|
}
|
|
1286
1448
|
if (verdict.level === "deny") {
|
|
1449
|
+
deliverNotes(false);
|
|
1287
1450
|
stats.held++;
|
|
1288
1451
|
track(true, "declined", "deny");
|
|
1289
1452
|
if (ctx.hasUI && config.notices)
|
|
@@ -1292,6 +1455,7 @@ export default function wardenExtension(pi) {
|
|
|
1292
1455
|
return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${deliveryReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
|
|
1293
1456
|
}
|
|
1294
1457
|
if (verdict.level !== "confirm") {
|
|
1458
|
+
deliverNotes(true);
|
|
1295
1459
|
track(false);
|
|
1296
1460
|
return undefined;
|
|
1297
1461
|
}
|
|
@@ -1304,6 +1468,7 @@ export default function wardenExtension(pi) {
|
|
|
1304
1468
|
if (dialogRule && ctx.hasUI) {
|
|
1305
1469
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1306
1470
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1471
|
+
deliverNotes(allowed);
|
|
1307
1472
|
if (allowed) {
|
|
1308
1473
|
track(true, "approved", "dialog");
|
|
1309
1474
|
return undefined;
|
|
@@ -1313,6 +1478,7 @@ export default function wardenExtension(pi) {
|
|
|
1313
1478
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1314
1479
|
}
|
|
1315
1480
|
if (mode === "advise") {
|
|
1481
|
+
deliverNotes(true);
|
|
1316
1482
|
stats.warned++;
|
|
1317
1483
|
if (ctx.hasUI && config.notices)
|
|
1318
1484
|
ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
|
|
@@ -1324,6 +1490,7 @@ export default function wardenExtension(pi) {
|
|
|
1324
1490
|
if (mode === "confirm") {
|
|
1325
1491
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1326
1492
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1493
|
+
deliverNotes(allowed);
|
|
1327
1494
|
if (allowed) {
|
|
1328
1495
|
track(true, "approved", "dialog");
|
|
1329
1496
|
return undefined;
|
|
@@ -1332,6 +1499,7 @@ export default function wardenExtension(pi) {
|
|
|
1332
1499
|
track(true, "declined", "dialog");
|
|
1333
1500
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1334
1501
|
}
|
|
1502
|
+
deliverNotes(false);
|
|
1335
1503
|
stats.held++;
|
|
1336
1504
|
actionGuard.hold(task);
|
|
1337
1505
|
track(true);
|
|
@@ -1346,6 +1514,13 @@ export default function wardenExtension(pi) {
|
|
|
1346
1514
|
return;
|
|
1347
1515
|
const textBlocks = event.content.filter(part => part.type === "text");
|
|
1348
1516
|
const text = textBlocks.map(part => part.text).join("\n");
|
|
1517
|
+
const scratch = scratchPending.get(event.toolCallId);
|
|
1518
|
+
if (scratch) {
|
|
1519
|
+
scratchPending.delete(event.toolCallId);
|
|
1520
|
+
for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
|
|
1521
|
+
sessionScratch.set(path, identity);
|
|
1522
|
+
}
|
|
1523
|
+
pruneScratch(sessionScratch);
|
|
1349
1524
|
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
1350
1525
|
const failed = resultFailed(event.isError, event.details, event.content);
|
|
1351
1526
|
// ── Conscience: track tool_result for the pending capability ──
|
|
@@ -1468,7 +1643,9 @@ export default function wardenExtension(pi) {
|
|
|
1468
1643
|
}
|
|
1469
1644
|
if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
|
|
1470
1645
|
ledger.candidate();
|
|
1471
|
-
|
|
1646
|
+
// Captured before the loop so the notice block below knows whether each block already carries its banner.
|
|
1647
|
+
const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
|
|
1648
|
+
if (bannersPlacedWithBlocks) {
|
|
1472
1649
|
// Retention and banners per block; block order and non-text parts are never touched.
|
|
1473
1650
|
content = [...content];
|
|
1474
1651
|
let textIndex = 0;
|
|
@@ -1536,8 +1713,11 @@ export default function wardenExtension(pi) {
|
|
|
1536
1713
|
}
|
|
1537
1714
|
if (key && !earlier)
|
|
1538
1715
|
ledger.remember(key, event.toolName, storedPath);
|
|
1716
|
+
// The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
|
|
1717
|
+
// when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
|
|
1718
|
+
// rides the result content alone and is never steered.
|
|
1539
1719
|
if (notice && textBlocks.length) {
|
|
1540
|
-
if (!
|
|
1720
|
+
if (!bannersPlacedWithBlocks) {
|
|
1541
1721
|
let index = 0;
|
|
1542
1722
|
content = content.map(part => {
|
|
1543
1723
|
if (part.type !== "text")
|
|
@@ -1546,13 +1726,12 @@ export default function wardenExtension(pi) {
|
|
|
1546
1726
|
return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
|
|
1547
1727
|
});
|
|
1548
1728
|
}
|
|
1549
|
-
const delivered = steer(config, "security", notice);
|
|
1550
1729
|
record(ctx, config, "security", renderTemplate(config.widget.security, {
|
|
1551
1730
|
tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
|
|
1552
1731
|
status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
|
|
1553
1732
|
}), [
|
|
1554
1733
|
`jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
|
|
1555
|
-
`output sample: ${redact(text).slice(0, 300)}`,
|
|
1734
|
+
`output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
|
|
1556
1735
|
]);
|
|
1557
1736
|
}
|
|
1558
1737
|
// Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
|
|
@@ -1742,11 +1921,13 @@ export default function wardenExtension(pi) {
|
|
|
1742
1921
|
catch {
|
|
1743
1922
|
toolInfos = [];
|
|
1744
1923
|
}
|
|
1745
|
-
const
|
|
1924
|
+
const prompt = latestUserPrompt(ctx);
|
|
1925
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1926
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1746
1927
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1747
1928
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1748
1929
|
try {
|
|
1749
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1930
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1750
1931
|
if (conscienceGeneration !== myGeneration)
|
|
1751
1932
|
return;
|
|
1752
1933
|
assessmentsThisPrompt++;
|
|
@@ -1776,7 +1957,7 @@ export default function wardenExtension(pi) {
|
|
|
1776
1957
|
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
1777
1958
|
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1778
1959
|
: undefined;
|
|
1779
|
-
const doneCheck = config.done.enabled &&
|
|
1960
|
+
const doneCheck = config.done.enabled && needsDoneCheck(evidence)
|
|
1780
1961
|
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1781
1962
|
: undefined;
|
|
1782
1963
|
if (proseCheck) {
|
|
@@ -1806,7 +1987,8 @@ export default function wardenExtension(pi) {
|
|
|
1806
1987
|
const verdict = await doneCheck;
|
|
1807
1988
|
if (verdict.error)
|
|
1808
1989
|
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1809
|
-
|
|
1990
|
+
// At most one nudge per user prompt; a later unverified claim is still judged and recorded.
|
|
1991
|
+
const nudge = verdict.unverified && config.done.nudge && !doneNudged ? doneNudge(verdict) : undefined;
|
|
1810
1992
|
record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
|
|
1811
1993
|
if (!verdict.unverified)
|
|
1812
1994
|
return;
|
|
@@ -1860,7 +2042,7 @@ export default function wardenExtension(pi) {
|
|
|
1860
2042
|
: `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
|
|
1861
2043
|
report([
|
|
1862
2044
|
`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}`,
|
|
1863
|
-
`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`}.`,
|
|
2045
|
+
`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, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${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`}.`,
|
|
1864
2046
|
formatSteers(stats),
|
|
1865
2047
|
`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}.`,
|
|
1866
2048
|
formatLedger(ledger.snapshot()),
|
|
@@ -1878,11 +2060,16 @@ export default function wardenExtension(pi) {
|
|
|
1878
2060
|
}
|
|
1879
2061
|
if (action === "trace") {
|
|
1880
2062
|
if (!ctx.hasUI) {
|
|
1881
|
-
|
|
1882
|
-
report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
|
|
2063
|
+
report(traceText());
|
|
1883
2064
|
return;
|
|
1884
2065
|
}
|
|
1885
|
-
togglePanel(ctx.ui, config);
|
|
2066
|
+
const opened = togglePanel(ctx.ui, config);
|
|
2067
|
+
// RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
|
|
2068
|
+
// is true there; only the host not asking for the component tells the two apart. Not awaited: in the
|
|
2069
|
+
// terminal `closed` settles when the user closes the sidebar.
|
|
2070
|
+
const fallback = () => { if (opened && !opened.built())
|
|
2071
|
+
ctx.ui.notify(traceText(), "info"); };
|
|
2072
|
+
void opened?.closed.then(fallback, fallback);
|
|
1886
2073
|
return;
|
|
1887
2074
|
}
|
|
1888
2075
|
if (action === "recommend") {
|
package/dist/guard.d.ts
CHANGED
|
@@ -199,6 +199,11 @@ export interface EvaluateOptions {
|
|
|
199
199
|
questions?: Questions | undefined;
|
|
200
200
|
/** Real paths the agent created under the temp directory in this session (`PatternOptions.scratch`). */
|
|
201
201
|
scratch?: ScratchRecords | undefined;
|
|
202
|
+
/**
|
|
203
|
+
* Real paths of host directories (`hostPaths()`). A write or edit in one is not held by the outside-project rule;
|
|
204
|
+
* every other check still applies.
|
|
205
|
+
*/
|
|
206
|
+
hostPaths?: readonly string[] | undefined;
|
|
202
207
|
}
|
|
203
208
|
export declare const higher: (a: Level, b: Level) => Level;
|
|
204
209
|
interface Rule {
|
|
@@ -213,6 +218,11 @@ export declare const SHELL_RULES: Rule[];
|
|
|
213
218
|
* subcommand for tools that have one (`git log`, `npm test`, `npm run build`). Undefined when no head can be read.
|
|
214
219
|
*/
|
|
215
220
|
export declare function commandFamily(command: string): string | undefined;
|
|
221
|
+
/**
|
|
222
|
+
* Whether a shell command has a segment whose effect is visible outside the working tree, decided in code before any
|
|
223
|
+
* request. Quoted data such as a commit message is blanked first, so a message that mentions `git push` is not a push.
|
|
224
|
+
*/
|
|
225
|
+
export declare function isVisibleCommand(command: string): boolean;
|
|
216
226
|
export interface ScannedCommand {
|
|
217
227
|
/** The command with data text blanked; what the pattern rules read. */
|
|
218
228
|
text: string;
|
|
@@ -249,6 +259,13 @@ export declare function tempRoots(): string[];
|
|
|
249
259
|
* a component exists but cannot be resolved (a dangling symlink, a loop, no permission).
|
|
250
260
|
*/
|
|
251
261
|
export declare function realTarget(path: string): string | undefined;
|
|
262
|
+
/**
|
|
263
|
+
* Host paths: directories outside the project where the host lets its agent write, from `PI_WARDEN_HOST_PATHS` only (a
|
|
264
|
+
* `:`-separated list). Relative entries, empty entries, and a filesystem root are ignored. Each entry is kept as its real
|
|
265
|
+
* path, so a target is compared after `..` and symlinks are resolved on both sides.
|
|
266
|
+
*/
|
|
267
|
+
export declare const HOST_PATHS_ENV = "PI_WARDEN_HOST_PATHS";
|
|
268
|
+
export declare function hostPaths(env?: NodeJS.ProcessEnv): string[];
|
|
252
269
|
/** The temp root a real path lies strictly under; a temp root itself has none. */
|
|
253
270
|
export declare function tempRootOf(real: string, roots?: string[]): string | undefined;
|
|
254
271
|
/** How much of the target trees one rm segment may walk: entries seen, and the clock time it must finish by. */
|
package/dist/guard.js
CHANGED
|
@@ -100,6 +100,38 @@ export function commandFamily(command) {
|
|
|
100
100
|
return head;
|
|
101
101
|
return rest[0] === "run" && word(rest[1]) ? `${head} run ${rest[1]}` : `${head} ${rest[0]}`;
|
|
102
102
|
}
|
|
103
|
+
/** Subcommands whose effect others see: history or remote state changes, a pull request, a release, a published package. */
|
|
104
|
+
const VISIBLE_SUBCOMMANDS = {
|
|
105
|
+
git: new Set(["push", "commit", "merge", "tag", "reset"]),
|
|
106
|
+
gh: new Set(["pr", "release"]),
|
|
107
|
+
npm: new Set(["publish"]),
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Whether a shell command has a segment whose effect is visible outside the working tree, decided in code before any
|
|
111
|
+
* request. Quoted data such as a commit message is blanked first, so a message that mentions `git push` is not a push.
|
|
112
|
+
*/
|
|
113
|
+
export function isVisibleCommand(command) {
|
|
114
|
+
// A substitution or subshell runs its command too: `out=$(git push …)`, `(cd repo && git push)`.
|
|
115
|
+
return splitShell(stripDataText(command).text).flatMap(segment => segment.split(/\$\(|`|\(/)).some(segment => {
|
|
116
|
+
const head = headOf(segment);
|
|
117
|
+
const subcommands = head ? VISIBLE_SUBCOMMANDS[head] : undefined;
|
|
118
|
+
if (!head || !subcommands)
|
|
119
|
+
return false;
|
|
120
|
+
const tokens = segment.trim().split(/\s+/);
|
|
121
|
+
// `git -C dir push`, `git -c key=value commit`: the option value is not the subcommand.
|
|
122
|
+
for (let index = tokens.findIndex(token => token.replace(/^.*\//, "") === head) + 1; index < tokens.length; index++) {
|
|
123
|
+
const token = tokens[index];
|
|
124
|
+
if (token === "-C" || token === "-c" || token === "-R" || token === "--repo") {
|
|
125
|
+
index++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (token.startsWith("-"))
|
|
129
|
+
continue;
|
|
130
|
+
return subcommands.has(token.replace(/[)`]+$/, ""));
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
103
135
|
/**
|
|
104
136
|
* Quoted strings replaced by a placeholder; escapes inside double quotes are honoured, single quotes take everything.
|
|
105
137
|
* A double-quoted string that substitutes a command (`"$(...)"`, backticks) executes it, so that string stays visible.
|
|
@@ -303,6 +335,30 @@ export function realTarget(path) {
|
|
|
303
335
|
}
|
|
304
336
|
}
|
|
305
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Host paths: directories outside the project where the host lets its agent write, from `PI_WARDEN_HOST_PATHS` only (a
|
|
340
|
+
* `:`-separated list). Relative entries, empty entries, and a filesystem root are ignored. Each entry is kept as its real
|
|
341
|
+
* path, so a target is compared after `..` and symlinks are resolved on both sides.
|
|
342
|
+
*/
|
|
343
|
+
export const HOST_PATHS_ENV = "PI_WARDEN_HOST_PATHS";
|
|
344
|
+
export function hostPaths(env = process.env) {
|
|
345
|
+
const roots = new Set();
|
|
346
|
+
for (const entry of (env[HOST_PATHS_ENV] ?? "").split(":")) {
|
|
347
|
+
if (!entry || !isAbsolute(entry))
|
|
348
|
+
continue;
|
|
349
|
+
const real = realTarget(entry);
|
|
350
|
+
if (real && dirname(real) !== real)
|
|
351
|
+
roots.add(real);
|
|
352
|
+
}
|
|
353
|
+
return [...roots];
|
|
354
|
+
}
|
|
355
|
+
/** Whether a target lies in a host path, after `..` and symlinks are resolved. An unresolvable target is not in one. */
|
|
356
|
+
function inHostPath(target, roots) {
|
|
357
|
+
if (!roots.length)
|
|
358
|
+
return false;
|
|
359
|
+
const real = realTarget(target);
|
|
360
|
+
return real !== undefined && roots.some(root => real === root || real.startsWith(root + sep));
|
|
361
|
+
}
|
|
306
362
|
/** The temp root a real path lies strictly under; a temp root itself has none. */
|
|
307
363
|
export function tempRootOf(real, roots = tempRoots()) {
|
|
308
364
|
return roots.find(root => real !== root && real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
@@ -1153,7 +1209,9 @@ export async function evaluateAction(action, options) {
|
|
|
1153
1209
|
reasons.push(`${hit.severity}: ${hit.label}`);
|
|
1154
1210
|
}
|
|
1155
1211
|
}
|
|
1156
|
-
|
|
1212
|
+
const inHost = summary.location === "outside_project" && typeof action.input.path === "string"
|
|
1213
|
+
&& inHostPath(resolve(action.cwd, action.input.path), options.hostPaths ?? []);
|
|
1214
|
+
if (summary.location === "outside_project" && !inHost) {
|
|
1157
1215
|
if (evidenceMode) {
|
|
1158
1216
|
const pathNote = action.tool === "write" && summary.exists
|
|
1159
1217
|
? `overwrites an existing file outside the project ${summary.path ?? ""}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-warden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
4
4
|
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|