pi-warden 0.8.0 → 0.9.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/README.md +165 -104
- package/dist/config.d.ts +20 -1
- package/dist/config.js +24 -1
- package/dist/extension.d.ts +8 -1
- package/dist/extension.js +74 -10
- package/dist/guard.d.ts +13 -0
- package/dist/guard.js +130 -6
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/panel.js +1 -1
- package/dist/rules.d.ts +164 -0
- package/dist/rules.js +479 -0
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +5 -2
- package/dist/trace.d.ts +3 -1
- package/dist/trace.js +14 -0
- package/dist/widget.d.ts +5 -0
- package/dist/widget.js +18 -0
- package/examples/README.md +45 -0
- package/examples/config.json +26 -0
- package/examples/pi-warden.json +22 -0
- package/examples/pi-warden.md +53 -0
- package/package.json +3 -2
package/dist/config.js
CHANGED
|
@@ -9,7 +9,7 @@ export function isRecallTool(value) {
|
|
|
9
9
|
}
|
|
10
10
|
export const PACKAGE_NAME = "pi-warden";
|
|
11
11
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
12
|
-
export const CONFIG_SCHEMA =
|
|
12
|
+
export const CONFIG_SCHEMA = 5;
|
|
13
13
|
export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
|
|
14
14
|
export function defaultConfig() {
|
|
15
15
|
return {
|
|
@@ -30,6 +30,7 @@ export function defaultConfig() {
|
|
|
30
30
|
done: { enabled: true, claimsDone: 0.7, nudge: true },
|
|
31
31
|
slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
|
|
32
32
|
security: { enabled: true, threshold: 0.7 },
|
|
33
|
+
rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
|
|
33
34
|
context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7 },
|
|
34
35
|
runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
|
|
35
36
|
notify: { enabled: true, cooldownMs: 10000, command: [] },
|
|
@@ -148,6 +149,26 @@ function applySlop(base, raw) {
|
|
|
148
149
|
// 0.2.x used `placeholder` for the stub threshold; it still sets the shared threshold.
|
|
149
150
|
return { enabled: boolean(raw.enabled, base.enabled), threshold: probability(raw.threshold ?? raw.placeholder, base.threshold), prose: applyProse(base.prose, raw.prose) };
|
|
150
151
|
}
|
|
152
|
+
function globList(value, fallback) {
|
|
153
|
+
return Array.isArray(value) ? value.filter((glob) => typeof glob === "string" && glob.trim().length > 0).map(glob => glob.trim()) : fallback;
|
|
154
|
+
}
|
|
155
|
+
function applyRules(base, raw) {
|
|
156
|
+
if (!isObject(raw))
|
|
157
|
+
return base;
|
|
158
|
+
const notes = isObject(raw.sensitivePaths)
|
|
159
|
+
? Object.fromEntries(Object.entries(raw.sensitivePaths).filter((entry) => entry[0].trim().length > 0 && typeof entry[1] === "string" && entry[1].trim().length > 0))
|
|
160
|
+
: base.sensitivePaths;
|
|
161
|
+
return {
|
|
162
|
+
enabled: boolean(raw.enabled, base.enabled),
|
|
163
|
+
threshold: probability(raw.threshold, base.threshold),
|
|
164
|
+
files: globList(raw.files, base.files),
|
|
165
|
+
fallback: boolean(raw.fallback, base.fallback),
|
|
166
|
+
maxChars: Math.max(500, positiveInteger(raw.maxChars, base.maxChars)),
|
|
167
|
+
exclude: globList(raw.exclude, base.exclude),
|
|
168
|
+
skip: globList(raw.skip, base.skip),
|
|
169
|
+
sensitivePaths: notes,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
151
172
|
function applyWidget(base, raw) {
|
|
152
173
|
if (!isObject(raw))
|
|
153
174
|
return base;
|
|
@@ -165,6 +186,7 @@ function applyWidget(base, raw) {
|
|
|
165
186
|
security: template(raw.security, base.security),
|
|
166
187
|
context: template(raw.context, base.context),
|
|
167
188
|
runaway: template(raw.runaway, base.runaway),
|
|
189
|
+
rules: template(raw.rules, base.rules),
|
|
168
190
|
};
|
|
169
191
|
}
|
|
170
192
|
/** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
|
|
@@ -177,6 +199,7 @@ function applyShared(base, raw) {
|
|
|
177
199
|
}
|
|
178
200
|
function applyGuards(base, raw, timeoutMs, source) {
|
|
179
201
|
return {
|
|
202
|
+
rules: applyRules(base.rules, raw.rules),
|
|
180
203
|
runaway: applyRunaway(base.runaway, raw.runaway),
|
|
181
204
|
// A project file may switch notifications off or on, but never names a command to run.
|
|
182
205
|
notify: applyNotify(base.notify, raw.notify, source === "user"),
|
package/dist/extension.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ToolCallRef } from "./action-guard.js";
|
|
3
3
|
import type { SlopSymptom, Verdict } from "./guard.js";
|
|
4
|
-
|
|
4
|
+
import type { ShapeResult } from "./shape.js";
|
|
5
|
+
export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
5
6
|
/**
|
|
6
7
|
* Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
|
|
7
8
|
* so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
|
|
@@ -10,5 +11,11 @@ export declare function siblingToolCalls(ctx: ExtensionContext): ToolCallRef[];
|
|
|
10
11
|
export declare function confirmMessage(verdict: Verdict): string;
|
|
11
12
|
/** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
|
|
12
13
|
export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
|
|
14
|
+
/**
|
|
15
|
+
* `completeConfig` in shape.ts fills sections an older config module lacks, but shape.ts can be the stale module too: the
|
|
16
|
+
* 0.8 version knew nothing of `rules`, so a 0.9 extension read `config.rules.enabled` on undefined and every tool call
|
|
17
|
+
* failed until Pi was restarted. The sections this build reads are therefore checked here as well, in the module that reads them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function guardCurrentSections(result: ShapeResult): ShapeResult;
|
|
13
20
|
/** Native Pi registration; importing the root library does not load this module. */
|
|
14
21
|
export default function wardenExtension(pi: ExtensionAPI): void;
|
package/dist/extension.js
CHANGED
|
@@ -10,18 +10,19 @@ import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
|
|
|
10
10
|
import { compressOutput, duplicateNote, evaluateOutput, outputKey, saveOutput, securityNotice } from "./output.js";
|
|
11
11
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
12
12
|
import { redact } from "./redact.js";
|
|
13
|
+
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
|
|
13
14
|
import { detectNotifier, sendNotification } from "./notify.js";
|
|
14
15
|
import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
|
|
15
16
|
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
|
|
16
17
|
import { openTracePanel } from "./panel.js";
|
|
17
18
|
import { completeConfig, shapeWarning } from "./shape.js";
|
|
18
19
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
19
|
-
import { actionDetails, doneDetails, proseDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
20
|
-
import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
|
|
21
|
-
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; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
20
|
+
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
21
|
+
import { DEFAULT_TEMPLATES, proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
|
|
22
|
+
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; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
22
23
|
const WIDGET = PACKAGE_NAME;
|
|
23
24
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
24
|
-
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0 });
|
|
25
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 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 });
|
|
25
26
|
function latestUserPrompt(ctx) {
|
|
26
27
|
const entries = ctx.sessionManager.getBranch();
|
|
27
28
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
@@ -114,6 +115,28 @@ export function slopSteer(where, symptoms, counts) {
|
|
|
114
115
|
const standing = symptoms.some(symptom => counts[symptom] >= 3) ? " Treat this as a standing rule for the rest of the session." : "";
|
|
115
116
|
return `pi-warden: the content just written to ${where} has ${named}. Fix it in your next edit: ${fixes}.${standing}`;
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* `completeConfig` in shape.ts fills sections an older config module lacks, but shape.ts can be the stale module too: the
|
|
120
|
+
* 0.8 version knew nothing of `rules`, so a 0.9 extension read `config.rules.enabled` on undefined and every tool call
|
|
121
|
+
* failed until Pi was restarted. The sections this build reads are therefore checked here as well, in the module that reads them.
|
|
122
|
+
*/
|
|
123
|
+
export function guardCurrentSections(result) {
|
|
124
|
+
const { config } = result;
|
|
125
|
+
const missing = [...result.missing];
|
|
126
|
+
if (typeof config.rules !== "object" || config.rules === null || !Array.isArray(config.rules.exclude)) {
|
|
127
|
+
if (!missing.includes("rules"))
|
|
128
|
+
missing.push("rules");
|
|
129
|
+
config.rules = { enabled: false, threshold: 1, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} };
|
|
130
|
+
}
|
|
131
|
+
if (typeof config.widget !== "object" || config.widget === null) {
|
|
132
|
+
if (!missing.includes("widget"))
|
|
133
|
+
missing.push("widget");
|
|
134
|
+
config.widget = { ...defaultConfig().widget, enabled: false };
|
|
135
|
+
}
|
|
136
|
+
if (typeof config.widget.rules !== "string")
|
|
137
|
+
config.widget = { ...config.widget, rules: DEFAULT_TEMPLATES.rules };
|
|
138
|
+
return { config, missing };
|
|
139
|
+
}
|
|
117
140
|
/** Native Pi registration; importing the root library does not load this module. */
|
|
118
141
|
export default function wardenExtension(pi) {
|
|
119
142
|
let client;
|
|
@@ -124,6 +147,7 @@ export default function wardenExtension(pi) {
|
|
|
124
147
|
let panel;
|
|
125
148
|
let lastUi;
|
|
126
149
|
const actionGuard = new ActionGuard();
|
|
150
|
+
const rulesGuard = new RulesGuard();
|
|
127
151
|
let attempts = new AttemptWindow(defaultConfig().stuck.window);
|
|
128
152
|
let evidence = emptyEvidence();
|
|
129
153
|
let doneNudged = false;
|
|
@@ -142,7 +166,7 @@ export default function wardenExtension(pi) {
|
|
|
142
166
|
// A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
|
|
143
167
|
let shapeReported = false;
|
|
144
168
|
const configFor = (ctx) => {
|
|
145
|
-
const { config, missing } = completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }));
|
|
169
|
+
const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
|
|
146
170
|
if (missing.length && !shapeReported) {
|
|
147
171
|
shapeReported = true;
|
|
148
172
|
// A namespace read stays undefined (not a link error) when an older config module lacks the export.
|
|
@@ -230,6 +254,7 @@ export default function wardenExtension(pi) {
|
|
|
230
254
|
trace.clear();
|
|
231
255
|
panel?.close();
|
|
232
256
|
actionGuard.reset();
|
|
257
|
+
rulesGuard.reset();
|
|
233
258
|
attempts.reset();
|
|
234
259
|
evidence = emptyEvidence();
|
|
235
260
|
doneNudged = false;
|
|
@@ -254,6 +279,7 @@ export default function wardenExtension(pi) {
|
|
|
254
279
|
runawayStops = 0;
|
|
255
280
|
pendingRunaway = undefined;
|
|
256
281
|
actionGuard.turnEnd();
|
|
282
|
+
rulesGuard.turnEnd();
|
|
257
283
|
});
|
|
258
284
|
// Each assistant message is judged on its own; Pi does not forward the stream's own "start" event, so this is the reset.
|
|
259
285
|
pi.on("message_start", async (event) => {
|
|
@@ -291,6 +317,7 @@ export default function wardenExtension(pi) {
|
|
|
291
317
|
pi.on("turn_end", async () => {
|
|
292
318
|
ledger.turnEnd();
|
|
293
319
|
actionGuard.turnEnd();
|
|
320
|
+
rulesGuard.turnEnd();
|
|
294
321
|
});
|
|
295
322
|
pi.on("tool_call", async (event, ctx) => {
|
|
296
323
|
const config = configFor(ctx);
|
|
@@ -311,9 +338,18 @@ export default function wardenExtension(pi) {
|
|
|
311
338
|
stats.inspected++;
|
|
312
339
|
const task = latestUserPrompt(ctx);
|
|
313
340
|
const judge = judgeFor(config);
|
|
314
|
-
const
|
|
341
|
+
const siblings = siblingToolCalls(ctx);
|
|
342
|
+
const call = { id: event.toolCallId, tool: event.toolName, input: event.input };
|
|
343
|
+
// The rules request carries the written content and the rule text, so it goes out beside the action request, not inside it.
|
|
344
|
+
const rulesCheck = config.rules.enabled && (event.toolName === "write" || event.toolName === "edit")
|
|
345
|
+
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
346
|
+
: undefined;
|
|
347
|
+
rulesCheck?.catch(() => undefined);
|
|
348
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security });
|
|
315
349
|
if (verdict.source === "skipped")
|
|
316
350
|
return;
|
|
351
|
+
// Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
|
|
352
|
+
const notes = [];
|
|
317
353
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
318
354
|
steer(config, "pi-warden: the proposed write may introduce a security weakness. Check for embedded credentials, disabled TLS, unsafe command/SQL interpolation, broad permissions, or bypassed verification; use a safe implementation instead.");
|
|
319
355
|
}
|
|
@@ -334,8 +370,35 @@ export default function wardenExtension(pi) {
|
|
|
334
370
|
const where = verdict.summary.path ?? event.toolName;
|
|
335
371
|
if (ctx.hasUI)
|
|
336
372
|
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
337
|
-
|
|
373
|
+
notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
|
|
374
|
+
}
|
|
375
|
+
if (rulesCheck) {
|
|
376
|
+
const rules = await rulesCheck;
|
|
377
|
+
if (rules.source !== "skipped") {
|
|
378
|
+
stats.ruleChecks++;
|
|
379
|
+
if (rules.error)
|
|
380
|
+
noteError(ctx, rules.error, rules.errorCode);
|
|
381
|
+
const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
382
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
|
|
383
|
+
if (told) {
|
|
384
|
+
stats.ruleViolations++;
|
|
385
|
+
if (ctx.hasUI)
|
|
386
|
+
ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
387
|
+
notes.push(told);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
|
|
391
|
+
if (hits.length && verdict.summary.path) {
|
|
392
|
+
stats.pathNotes++;
|
|
393
|
+
const told = pathNoteSteer(verdict.summary.path, hits);
|
|
394
|
+
record(ctx, config, "rules", renderTemplate(config.widget.rules, { tool: event.toolName, path: verdict.summary.path, violations: `sensitive path ${hits.map(hit => hit.glob).join(", ")}`, status: "note" }), [`${event.toolName} ${verdict.summary.path} matches ${hits.map(hit => hit.glob).join(", ")} in rules.sensitivePaths`, `agent told: ${told}`]);
|
|
395
|
+
if (ctx.hasUI)
|
|
396
|
+
ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
|
|
397
|
+
notes.push(told);
|
|
398
|
+
}
|
|
338
399
|
}
|
|
400
|
+
if (notes.length)
|
|
401
|
+
steer(config, notes.join("\n\n"));
|
|
339
402
|
if (verdict.level === "warn") {
|
|
340
403
|
stats.warned++;
|
|
341
404
|
if (ctx.hasUI)
|
|
@@ -578,12 +641,13 @@ export default function wardenExtension(pi) {
|
|
|
578
641
|
const key = resolveApiKey();
|
|
579
642
|
const source = consentSource(config);
|
|
580
643
|
const usage = client?.getUsage();
|
|
581
|
-
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.context.enabled && "context", config.runaway.enabled && "runaway", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
|
|
644
|
+
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.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
|
|
582
645
|
report([
|
|
583
646
|
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
|
|
584
|
-
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${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)"}.`,
|
|
585
|
-
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.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}.`,
|
|
647
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${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.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)"}.`,
|
|
648
|
+
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; 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}.`,
|
|
586
649
|
formatLedger(ledger.snapshot()),
|
|
650
|
+
`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"}.`,
|
|
587
651
|
`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"}.`,
|
|
588
652
|
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
589
653
|
widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
|
package/dist/guard.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ActionSummary {
|
|
|
37
37
|
newText: string;
|
|
38
38
|
}>;
|
|
39
39
|
input?: string;
|
|
40
|
+
/** Present when part of the command is data (a heredoc body, a quoted message), so a destructive string inside it is payload. */
|
|
41
|
+
dataText?: string;
|
|
40
42
|
}
|
|
41
43
|
export type ScopeLabel = "expected_step" | "plausible_side_step" | "unrelated" | "unclear";
|
|
42
44
|
export interface Judgment {
|
|
@@ -91,6 +93,17 @@ export interface EvaluateOptions {
|
|
|
91
93
|
/** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
|
|
92
94
|
retryAfterHold?: boolean | undefined;
|
|
93
95
|
}
|
|
96
|
+
export interface ScannedCommand {
|
|
97
|
+
/** The command with data text blanked; what the pattern rules read. */
|
|
98
|
+
text: string;
|
|
99
|
+
/** True when a heredoc body or quoted data was removed. */
|
|
100
|
+
stripped: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Removes heredoc bodies that are not fed to a shell and quoted arguments of data commands. Interpreter heredocs
|
|
104
|
+
* (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
|
|
105
|
+
*/
|
|
106
|
+
export declare function stripDataText(command: string): ScannedCommand;
|
|
94
107
|
export declare function matchPatterns(tool: string, input: Record<string, unknown>, cwd?: string): PatternHit[];
|
|
95
108
|
export declare function isReadOnlyCommand(command: string): boolean;
|
|
96
109
|
export declare function describeAction(tool: string, input: Record<string, unknown>, cwd: string): ActionSummary;
|
package/dist/guard.js
CHANGED
|
@@ -52,12 +52,131 @@ const SENSITIVE_PATH = /(?:^|[\s/"'=:(])\.env(?:\.(?!example\b|sample\b|template
|
|
|
52
52
|
function splitShell(command) {
|
|
53
53
|
return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim()).filter(Boolean);
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Data text: a heredoc body written to a file, a quoted message, or a search pattern is not a command. Pattern rules skip
|
|
57
|
+
// it so a test fixture or a commit message that mentions `git push --force` is not held. A shell sink anywhere in the
|
|
58
|
+
// command (sh, eval, bash -c, command substitution) keeps every byte in scope, because the payload is executed.
|
|
59
|
+
const WRAPPERS = new Set(["sudo", "nohup", "time", "env", "command", "builtin", "exec", "nice", "timeout", "doas"]);
|
|
60
|
+
const SHELL_SINKS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "source", ".", "xargs", "su"]);
|
|
61
|
+
/** Commands whose quoted arguments are text they print, search, or record. */
|
|
62
|
+
const DATA_HEADS = new Set(["echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "ugrep", "jq", "cat", "tee", "head", "tail", "wc", "sort", "uniq", "cut", "tr", "less", "more", "test", "["]);
|
|
63
|
+
const GIT_MESSAGE_SUBCOMMANDS = new Set(["commit", "tag", "notes", "merge", "stash"]);
|
|
64
|
+
/** Interpreters whose stdin script can still run shell commands; their heredoc bodies stay in scope when they do. */
|
|
65
|
+
const INTERPRETERS = /^(?:python[\d.]*|node|ruby|perl|php|deno|bun|tsx|Rscript|lua[\d.]*)$/;
|
|
66
|
+
const EXEC_CALLS = /\b(?:os\.system|os\.popen|os\.exec\w*|subprocess|child_process|execSync|spawnSync|execFileSync|spawn\(|exec\(|system\(|popen\(|shell_exec|passthru|proc_open|Open3|IO\.popen|Deno\.run|Deno\.Command|Bun\.spawn|Bun\.\$|%x[\[{(]|`[^`\n]*\b(?:rm|git|dd|mkfs|kubectl|terraform)\b)/;
|
|
67
|
+
const HEREDOC = /<<-?\s*(?:"(\w+)"|'(\w+)'|(\\)?(\w+))/;
|
|
68
|
+
const SUBSTITUTION = /\$\(|`/;
|
|
69
|
+
function headOf(segment) {
|
|
70
|
+
const tokens = segment.trim().split(/\s+/);
|
|
71
|
+
let index = 0;
|
|
72
|
+
while (index < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]) || WRAPPERS.has(tokens[index])))
|
|
73
|
+
index++;
|
|
74
|
+
const head = tokens[index];
|
|
75
|
+
return head ? head.replace(/^.*\//, "") : undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Quoted strings replaced by a placeholder; escapes inside double quotes are honoured, single quotes take everything.
|
|
79
|
+
* A double-quoted string that substitutes a command (`"$(...)"`, backticks) executes it, so that string stays visible.
|
|
80
|
+
*/
|
|
81
|
+
function blankQuotes(segment) {
|
|
82
|
+
let out = "";
|
|
83
|
+
for (let index = 0; index < segment.length; index++) {
|
|
84
|
+
const char = segment[index];
|
|
85
|
+
if (char !== "'" && char !== "\"") {
|
|
86
|
+
out += char;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let end = index + 1;
|
|
90
|
+
while (end < segment.length && segment[end] !== char)
|
|
91
|
+
end += char === "\"" && segment[end] === "\\" ? 2 : 1;
|
|
92
|
+
if (end >= segment.length) {
|
|
93
|
+
out += segment.slice(index);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const inner = segment.slice(index + 1, end);
|
|
97
|
+
out += char === "\"" && SUBSTITUTION.test(inner) ? `${char}${inner}${char}` : `${char}[text]${char}`;
|
|
98
|
+
index = end;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function isDataSegment(segment) {
|
|
103
|
+
const head = headOf(segment);
|
|
104
|
+
if (!head)
|
|
105
|
+
return false;
|
|
106
|
+
if (head === "git") {
|
|
107
|
+
const sub = segment.trim().split(/\s+/).find(token => !token.startsWith("-") && token !== "git" && !WRAPPERS.has(token));
|
|
108
|
+
return sub !== undefined && GIT_MESSAGE_SUBCOMMANDS.has(sub) && !/\s-c\s|--config/.test(segment);
|
|
109
|
+
}
|
|
110
|
+
if (head === "gh")
|
|
111
|
+
return /\s--(?:body|title|notes)\b/.test(segment) || /\s-[bt]\s/.test(segment);
|
|
112
|
+
return DATA_HEADS.has(head);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Removes heredoc bodies that are not fed to a shell and quoted arguments of data commands. Interpreter heredocs
|
|
116
|
+
* (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
|
|
117
|
+
*/
|
|
118
|
+
export function stripDataText(command) {
|
|
119
|
+
const lines = command.split("\n");
|
|
120
|
+
const out = [];
|
|
121
|
+
let stripped = false;
|
|
122
|
+
for (let index = 0; index < lines.length; index++) {
|
|
123
|
+
const line = lines[index];
|
|
124
|
+
const heredoc = HEREDOC.exec(line);
|
|
125
|
+
if (!heredoc) {
|
|
126
|
+
out.push(line);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const delimiter = heredoc[1] ?? heredoc[2] ?? heredoc[4];
|
|
130
|
+
// 'EOF', "EOF", and \EOF make the body literal; a bare EOF body is expanded, so a substitution inside it runs.
|
|
131
|
+
const literal = heredoc[4] === undefined || heredoc[3] !== undefined;
|
|
132
|
+
const body = [];
|
|
133
|
+
let close = index + 1;
|
|
134
|
+
while (close < lines.length && lines[close].replace(/^\t+/, "") !== delimiter)
|
|
135
|
+
body.push(lines[close]), close++;
|
|
136
|
+
const bodyText = body.join("\n");
|
|
137
|
+
// The whole pipeline on the heredoc line counts: `cat <<EOF | bash` executes the body as much as `bash <<EOF` does.
|
|
138
|
+
const heads = splitShell(line).map(segment => headOf(segment) ?? "");
|
|
139
|
+
const consumer = headOf(line.slice(0, heredoc.index)) ?? "";
|
|
140
|
+
const executed = heads.some(head => SHELL_SINKS.has(head)) || (INTERPRETERS.test(consumer) && EXEC_CALLS.test(bodyText)) || (!literal && SUBSTITUTION.test(bodyText));
|
|
141
|
+
out.push(line);
|
|
142
|
+
if (executed)
|
|
143
|
+
out.push(...body);
|
|
144
|
+
else if (body.length) {
|
|
145
|
+
out.push(`[heredoc body: ${body.length} lines of data]`);
|
|
146
|
+
stripped = true;
|
|
147
|
+
}
|
|
148
|
+
if (close < lines.length)
|
|
149
|
+
out.push(lines[close]);
|
|
150
|
+
index = close;
|
|
151
|
+
}
|
|
152
|
+
const joined = out.join("\n");
|
|
153
|
+
const segments = splitShell(joined);
|
|
154
|
+
// A shell sink anywhere may run text written earlier in the same command (`cat <<EOF > run.sh` then `bash run.sh`), so nothing is treated as data.
|
|
155
|
+
if (segments.some(segment => { const head = headOf(segment); return head !== undefined && SHELL_SINKS.has(head); }))
|
|
156
|
+
return { text: command, stripped: false };
|
|
157
|
+
if (/\b(?:ba|z|da|k)?sh\s+-[a-zA-Z]*c\b/.test(joined))
|
|
158
|
+
return { text: command, stripped: false };
|
|
159
|
+
let text = joined;
|
|
160
|
+
for (const segment of segments) {
|
|
161
|
+
if (!isDataSegment(segment) || !/["']/.test(segment))
|
|
162
|
+
continue;
|
|
163
|
+
const blanked = blankQuotes(segment);
|
|
164
|
+
if (blanked === segment)
|
|
165
|
+
continue;
|
|
166
|
+
text = text.replace(segment, blanked);
|
|
167
|
+
stripped = true;
|
|
168
|
+
}
|
|
169
|
+
return { text, stripped };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are
|
|
173
|
+
* risky. A quote or parenthesis before `rm` is allowed so a quoted or substituted command is read; data quotes were blanked before this runs.
|
|
174
|
+
*/
|
|
56
175
|
function classifyRm(segment, cwd) {
|
|
57
|
-
const match = /(
|
|
176
|
+
const match = /(?:^|[\s"'(])rm\s+(.*)$/.exec(segment);
|
|
58
177
|
if (!match)
|
|
59
178
|
return undefined;
|
|
60
|
-
const tokens = match[1].split(/\s+/).filter(Boolean);
|
|
179
|
+
const tokens = match[1].split(/\s+/).filter(Boolean).map(token => token.replace(/["')]+$/, ""));
|
|
61
180
|
const flags = tokens.filter(token => token.startsWith("-"));
|
|
62
181
|
const targets = tokens.filter(token => !token.startsWith("-"));
|
|
63
182
|
const recursive = flags.some(flag => flag === "--recursive" || (/^-[a-zA-Z]+$/.test(flag) && /[rR]/.test(flag)));
|
|
@@ -86,8 +205,9 @@ export function matchPatterns(tool, input, cwd) {
|
|
|
86
205
|
const hits = new Map();
|
|
87
206
|
const add = (hit) => { if (hit && !hits.has(hit.id))
|
|
88
207
|
hits.set(hit.id, hit); };
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
208
|
+
const raw = commandOf(tool, input)?.command;
|
|
209
|
+
if (raw) {
|
|
210
|
+
const command = stripDataText(raw).text;
|
|
91
211
|
for (const rule of SHELL_RULES)
|
|
92
212
|
if (rule.test.test(command))
|
|
93
213
|
add({ id: rule.id, severity: rule.severity, label: rule.label });
|
|
@@ -174,8 +294,12 @@ function displayPath(target, cwd) {
|
|
|
174
294
|
export function describeAction(tool, input, cwd) {
|
|
175
295
|
const summary = { tool };
|
|
176
296
|
const view = commandOf(tool, input);
|
|
177
|
-
if (view)
|
|
297
|
+
if (view) {
|
|
178
298
|
summary.command = redact(truncate(view.command, COMMAND_LIMIT));
|
|
299
|
+
// Jev sees the full text; this names the part of it that is written or printed rather than executed.
|
|
300
|
+
if (stripDataText(view.command).stripped)
|
|
301
|
+
summary.dataText = "heredoc bodies and quoted arguments of echo/printf/grep/git commit in this command are text that is written, printed, searched, or recorded, not executed";
|
|
302
|
+
}
|
|
179
303
|
if (typeof input.path === "string" && input.path.trim() && tool !== "ctx_execute_file") {
|
|
180
304
|
const shown = displayPath(input.path, cwd);
|
|
181
305
|
summary.path = shown.path;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
|
|
2
|
-
export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
|
|
2
|
+
export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScannedCommand, ScopeLabel, Severity, Verdict } from "./guard.js";
|
|
3
3
|
export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
|
|
4
4
|
export { ActionGuard } from "./action-guard.js";
|
|
5
5
|
export type { Conversation, InspectOptions, ToolCallRef } from "./action-guard.js";
|
|
@@ -25,10 +25,12 @@ export { ContextLedger, formatLedger } from "./saver.js";
|
|
|
25
25
|
export type { ContextLedgerSnapshot, RecallKind } from "./saver.js";
|
|
26
26
|
export type { ShapeResult } from "./shape.js";
|
|
27
27
|
export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
28
|
-
export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, RunawayConfig, NotifyConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
|
|
28
|
+
export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, RulesConfig, ContextConfig, RunawayConfig, NotifyConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
|
|
29
29
|
export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
|
|
30
30
|
export type { CommandView } from "./tools.js";
|
|
31
|
-
export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
|
|
31
|
+
export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
|
|
32
32
|
export type { WidgetConfig, WidgetPlacement, Tokens } from "./widget.js";
|
|
33
|
-
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails } from "./trace.js";
|
|
33
|
+
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
|
|
34
34
|
export type { TraceEntry, GuardName } from "./trace.js";
|
|
35
|
+
export { parseRules, condense, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
|
36
|
+
export type { Rule, RuleSet, RuleOutcome, RuleScore, RuleFinding, RulesVerdict, RulesOptions, RulesTarget, EditView, PathNote, RulesCallRef } from "./rules.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
|
|
2
2
|
export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
|
|
3
3
|
export { ActionGuard } from "./action-guard.js";
|
|
4
4
|
export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
|
|
@@ -14,5 +14,6 @@ export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
|
|
|
14
14
|
export { ContextLedger, formatLedger } from "./saver.js";
|
|
15
15
|
export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
16
16
|
export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
|
|
17
|
-
export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
|
|
18
|
-
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails } from "./trace.js";
|
|
17
|
+
export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
|
|
18
|
+
export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
|
|
19
|
+
export { parseRules, condense, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
|
package/dist/panel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error", stopped: "error", "stopped, recovering": "error" };
|
|
2
|
+
const LEVEL_COLOR = { allow: "success", ok: "success", warn: "warning", unverified: "warning", nudged: "warning", confirm: "error", stuck: "error", "false claim": "error", stopped: "error", "stopped, recovering": "error", violation: "warning", skipped: "muted" };
|
|
3
3
|
/**
|
|
4
4
|
* Sidebar listing the trace newest-first, live-updating while open. It opens without taking keyboard input: the editor
|
|
5
5
|
* keeps working while it is visible. A click inside it (fullscreen mode) focuses it; then ↑/↓/j/k scroll a line,
|