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/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 = 4;
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"),
@@ -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
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and 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.";
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 verdict = await actionGuard.inspect({ id: event.toolCallId, tool: event.toolName, input: event.input }, { task, context: recentTaskContext(ctx), siblings: siblingToolCalls(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security });
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
- steer(config, slopSteer(where, verdict.slopSymptoms, slopCounts));
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
- /** rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are risky. */
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 = /(?:^|\s)rm\s+(.*)$/.exec(segment);
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 command = commandOf(tool, input)?.command;
90
- if (command) {
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,