pi-warden 0.40.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/guard.js CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { ask, choice, noul, score } from "pi-typesafe";
5
5
  import { redact } from "./redact.js";
6
+ import { SPINE_GOAL_LIMIT, SPINE_HISTORY_LIMIT, SPINE_HISTORY_TURNS } from "./shape.js";
6
7
  import { globToRegExp } from "./rules.js";
7
8
  import { resolveRulesFile } from "./rules-file.js";
8
9
  import { COMMAND_TOOLS, commandOf } from "./tools.js";
@@ -79,6 +80,26 @@ function headOf(segment) {
79
80
  const head = tokens[index];
80
81
  return head ? head.replace(/^.*\//, "") : undefined;
81
82
  }
83
+ /** Tools whose first word names what runs: `git log` and `git status` print very different amounts. */
84
+ const SUBCOMMAND_HEADS = new Set(["git", "npm", "pnpm", "yarn", "bun", "cargo", "go", "docker", "kubectl"]);
85
+ /**
86
+ * The command family the large-output steer is remembered by: the head of the first segment that is not a `cd`, plus the
87
+ * subcommand for tools that have one (`git log`, `npm test`, `npm run build`). Undefined when no head can be read.
88
+ */
89
+ export function commandFamily(command) {
90
+ const segment = splitShell(command).find(part => { const head = headOf(part); return head !== undefined && head !== "cd" && head !== "pushd"; });
91
+ const head = segment ? headOf(segment) : undefined;
92
+ if (!segment || !head)
93
+ return undefined;
94
+ if (!SUBCOMMAND_HEADS.has(head))
95
+ return head;
96
+ const tokens = segment.trim().split(/\s+/);
97
+ const rest = tokens.slice(tokens.findIndex(token => token.replace(/^.*\//, "") === head) + 1).filter(token => !token.startsWith("-"));
98
+ const word = (token) => token !== undefined && /^[A-Za-z][\w:.-]*$/.test(token);
99
+ if (!word(rest[0]))
100
+ return head;
101
+ return rest[0] === "run" && word(rest[1]) ? `${head} run ${rest[1]}` : `${head} ${rest[0]}`;
102
+ }
82
103
  /**
83
104
  * Quoted strings replaced by a placeholder; escapes inside double quotes are honoured, single quotes take everything.
84
105
  * A double-quoted string that substitutes a command (`"$(...)"`, backticks) executes it, so that string stays visible.
@@ -177,7 +198,7 @@ export function stripDataText(command) {
177
198
  * rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are
178
199
  * risky. A quote or parenthesis before `rm` is allowed so a quoted or substituted command is read; data quotes were blanked before this runs.
179
200
  */
180
- function classifyRm(segment, cwd) {
201
+ function classifyRm(segment, cwd, scratch) {
181
202
  const match = /(?:^|[\s"'(])rm\s+(.*)$/.exec(segment);
182
203
  if (!match)
183
204
  return undefined;
@@ -196,6 +217,10 @@ function classifyRm(segment, cwd) {
196
217
  return cwd ? !isInside(clean, cwd) : true;
197
218
  return clean.split(/[\\/]/).includes("..");
198
219
  });
220
+ const budget = scratchBudget();
221
+ if (dangerousTarget && scratch?.size && targets.length && targets.every(target => isSessionScratch(target, scratch, budget))) {
222
+ return { id: "rm-session-scratch", severity: "risky", label: "recursive rm of session scratch: every target is under the temp directory and was created in this session" };
223
+ }
199
224
  if (dangerousTarget)
200
225
  return { id: "rm-recursive-dangerous-target", severity: "destructive", label: "recursive rm on an absolute, home, variable, or parent path" };
201
226
  if (force)
@@ -206,6 +231,273 @@ function isInside(target, cwd) {
206
231
  const rel = relative(resolve(cwd), resolve(target));
207
232
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
208
233
  }
234
+ // ---------------------------------------------------------------------------
235
+ // Session scratch: paths the agent created under the OS temp directory in this session. A recursive rm whose every
236
+ // target is such a path, after symlinks are resolved, is risky rather than destructive. Everything here fails closed:
237
+ // a path that cannot be resolved, or that uses shell expansion, is not scratch.
238
+ /**
239
+ * Whether the exemption applies on this platform. It rests on birth time being a real creation time: macOS and Windows
240
+ * report one; Linux without `statx` reports ctime, which `mv` updates, so moved-in content would pass the tree walk.
241
+ * Elsewhere nothing is recorded and a recursive rm is classified as if no scratch existed.
242
+ */
243
+ export function scratchPlatform(platform = process.platform) {
244
+ return platform === "darwin" || platform === "win32";
245
+ }
246
+ /** The identity of a path itself (a symlink is not followed); undefined when it does not exist. */
247
+ export function scratchIdentity(path) {
248
+ try {
249
+ const stats = lstatSync(path);
250
+ return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs };
251
+ }
252
+ catch {
253
+ return undefined;
254
+ }
255
+ }
256
+ function sameIdentity(a, b) {
257
+ return a !== undefined && a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
258
+ }
259
+ /** Drops records whose path is gone or now names a different file, so a later file at the same path is not scratch. */
260
+ export function pruneScratch(records) {
261
+ for (const [path, identity] of records)
262
+ if (!sameIdentity(scratchIdentity(path), identity))
263
+ records.delete(path);
264
+ }
265
+ /** Real paths of the temp roots that exist: `os.tmpdir()`, `$TMPDIR`, `/tmp`, `/private/tmp`. */
266
+ export function tempRoots() {
267
+ const roots = new Set();
268
+ for (const root of [tmpdir(), process.env.TMPDIR, "/tmp", "/private/tmp"]) {
269
+ if (!root || !isAbsolute(root))
270
+ continue;
271
+ try {
272
+ roots.add(realpathSync(root));
273
+ }
274
+ catch { /* absent on this system */ }
275
+ }
276
+ return [...roots];
277
+ }
278
+ /**
279
+ * The real path of an absolute path: the realpath of its deepest existing ancestor plus the missing rest. Undefined when
280
+ * a component exists but cannot be resolved (a dangling symlink, a loop, no permission).
281
+ */
282
+ export function realTarget(path) {
283
+ const rest = [];
284
+ let head = resolve(path);
285
+ for (;;) {
286
+ try {
287
+ return join(realpathSync(head), ...rest.reverse());
288
+ }
289
+ catch (error) {
290
+ const code = error.code;
291
+ if (code !== "ENOENT" && code !== "ENOTDIR")
292
+ return undefined;
293
+ try {
294
+ lstatSync(head);
295
+ return undefined;
296
+ }
297
+ catch { /* missing: resolve the parent */ }
298
+ const parent = dirname(head);
299
+ if (parent === head)
300
+ return undefined;
301
+ rest.push(basename(head));
302
+ head = parent;
303
+ }
304
+ }
305
+ }
306
+ /** The temp root a real path lies strictly under; a temp root itself has none. */
307
+ export function tempRootOf(real, roots = tempRoots()) {
308
+ return roots.find(root => real !== root && real.startsWith(root.endsWith(sep) ? root : root + sep));
309
+ }
310
+ /** A privilege-raising command word anywhere in a command. */
311
+ const PRIVILEGED = /(?:^|[\s;&|("'`])(?:sudo|doas|su|pkexec|run0)(?=\s|$)/m;
312
+ /** A literal absolute path: no quotes left inside, no glob, brace, tilde, variable, escape, or substitution. */
313
+ const LITERAL_PATH = /^\/[^*?[\]{}$`~\\"'\s]*$/;
314
+ /**
315
+ * 10,000 entries covers a generated test tree of a few thousand directories with room to spare, and 200 ms keeps the
316
+ * synchronous walk in the tool-call hook short. A tree that needs more is not checked, so it keeps the hold.
317
+ */
318
+ const scratchBudget = () => ({ entries: 10_000, deadline: Date.now() + 200 });
319
+ /**
320
+ * True when every entry of the tree at `path`, the root included, has a birth time at or after `born`. Symlinks are
321
+ * checked as links and never followed. False when the root is missing, an entry has no birth time or cannot be read,
322
+ * or the walk runs past its budget: content moved in from elsewhere keeps its older birth time and must not pass.
323
+ */
324
+ export function bornAfter(path, born, budget = scratchBudget()) {
325
+ if (!(born > 0))
326
+ return false;
327
+ const stack = [path];
328
+ while (stack.length) {
329
+ if (--budget.entries < 0 || Date.now() > budget.deadline)
330
+ return false;
331
+ const current = stack.pop();
332
+ try {
333
+ const stats = lstatSync(current);
334
+ if (!(stats.birthtimeMs > 0) || stats.birthtimeMs < born)
335
+ return false;
336
+ if (stats.isDirectory())
337
+ for (const name of readdirSync(current))
338
+ stack.push(join(current, name));
339
+ }
340
+ catch {
341
+ return false;
342
+ }
343
+ }
344
+ return true;
345
+ }
346
+ function isSessionScratch(target, scratch, budget) {
347
+ const clean = target.replace(/^["']|["']$/g, "");
348
+ if (!LITERAL_PATH.test(clean) || clean.split("/").includes(".."))
349
+ return false;
350
+ const real = realTarget(clean);
351
+ const root = real === undefined ? undefined : tempRootOf(real);
352
+ if (!real || !root)
353
+ return false;
354
+ for (let path = real; path !== root && path.startsWith(root); path = dirname(path)) {
355
+ const recorded = scratch.get(path);
356
+ if (recorded)
357
+ return sameIdentity(scratchIdentity(path), recorded) && bornAfter(real, recorded.birthtimeMs, budget);
358
+ }
359
+ return false;
360
+ }
361
+ /** `mkdir` targets in a command that are literal absolute paths; flags and relative operands are skipped. */
362
+ function mkdirTargets(command) {
363
+ const targets = [];
364
+ for (const segment of splitShell(stripDataText(command).text)) {
365
+ if (headOf(segment) !== "mkdir")
366
+ continue;
367
+ const tokens = segment.trim().split(/\s+/);
368
+ for (const token of tokens.slice(tokens.findIndex(token => token.replace(/^.*\//, "") === "mkdir") + 1)) {
369
+ const clean = token.replace(/^["']|["']$/g, "");
370
+ if (LITERAL_PATH.test(clean) && !clean.split("/").includes(".."))
371
+ targets.push(clean);
372
+ }
373
+ }
374
+ return targets;
375
+ }
376
+ /**
377
+ * Real paths under a temp root that this call may create and that do not exist yet: `mkdir` targets of a shell command,
378
+ * or a written file. Each path and its missing ancestors below the temp root are listed. Taken before the call runs, so
379
+ * a directory that already existed is never recorded as created.
380
+ */
381
+ export function scratchCandidates(tool, input, cwd, platform = process.platform) {
382
+ if (!scratchPlatform(platform))
383
+ return [];
384
+ const command = tool === "bash" ? commandOf(tool, input)?.command : undefined;
385
+ const paths = command ? mkdirTargets(command) : tool === "write" && typeof input.path === "string" && !input.path.startsWith("~") ? [resolve(cwd, input.path)] : [];
386
+ const roots = tempRoots();
387
+ const missing = new Set();
388
+ for (const path of paths) {
389
+ const real = realTarget(path);
390
+ const root = real === undefined ? undefined : tempRootOf(real, roots);
391
+ if (!real || !root)
392
+ continue;
393
+ for (let current = real; current !== root && !existsSync(current); current = dirname(current))
394
+ missing.add(current);
395
+ }
396
+ return [...missing];
397
+ }
398
+ /** At most this many temp paths are read from one tool result. */
399
+ const SCRATCH_OUTPUT_LIMIT = 20;
400
+ /** Absolute paths in text that start with a temp root, as spelled or as resolved. */
401
+ function tempPathsIn(text, roots) {
402
+ const prefixes = new Set(roots);
403
+ for (const root of [tmpdir(), process.env.TMPDIR, "/tmp", "/private/tmp"])
404
+ if (root && isAbsolute(root))
405
+ prefixes.add(root.replace(/\/+$/, ""));
406
+ const alternatives = [...prefixes].map(prefix => prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
407
+ const pattern = new RegExp(`(?:^|[\\s"'=:(\\[])((?:${alternatives})/[^\\s"'\`:,;()[\\]{}<>*?$\\\\]+)`, "gm");
408
+ const found = new Set();
409
+ for (const match of text.matchAll(pattern)) {
410
+ found.add(match[1].replace(/[./]+$/, ""));
411
+ if (found.size >= SCRATCH_OUTPUT_LIMIT)
412
+ break;
413
+ }
414
+ return [...found];
415
+ }
416
+ /**
417
+ * Real paths the call created, read after it ran, each with its identity. A candidate from `scratchCandidates` counts
418
+ * when it now exists as itself (not through a symlink). For a shell command, absolute temp paths printed in its output
419
+ * also count, bounded: the first 20 such paths only, each must exist under a temp root as a real directory or file, and
420
+ * its birth time must fall in a later millisecond than `started` (`Date.now()` when the call began). Where the file
421
+ * system reports no birth time, a printed path counts only from a command that does nothing but run `mktemp` and print
422
+ * what it made (`mktempOnly`). `birthtime` reads a file's birth time in milliseconds, 0 when the file system has none.
423
+ * Nothing is recorded where `scratchPlatform` is false.
424
+ */
425
+ export function createdScratch(tool, input, output, started, candidates, birthtime = (stats) => stats.birthtimeMs, platform = process.platform) {
426
+ if (!scratchPlatform(platform))
427
+ return new Map();
428
+ const found = [];
429
+ for (const path of candidates) {
430
+ try {
431
+ if (realpathSync(path) === path)
432
+ found.push(path);
433
+ }
434
+ catch { /* not created */ }
435
+ }
436
+ const command = tool === "bash" ? commandOf(tool, input)?.command : undefined;
437
+ if (command)
438
+ found.push(...printedScratch(command, output, started, birthtime));
439
+ const created = new Map();
440
+ for (const path of found) {
441
+ const identity = scratchIdentity(path);
442
+ if (identity)
443
+ created.set(path, identity);
444
+ }
445
+ return created;
446
+ }
447
+ /** Arguments of a `mktemp` that creates something: literal words only, and no `-u`/`--dry-run`. */
448
+ const MKTEMP_ARGS = String.raw `((?:\s+[^\s$\`<>()"']+)*)`;
449
+ const MKTEMP_ALONE = new RegExp(String.raw `^mktemp${MKTEMP_ARGS}$`);
450
+ const MKTEMP_ASSIGN = new RegExp(String.raw `^([A-Za-z_]\w*)=("?)\$\(\s*mktemp${MKTEMP_ARGS}\s*\)\2$`);
451
+ const ECHO_VARS = /^echo((?:\s+"?\$\{?[A-Za-z_]\w*\}?"?)+)$/;
452
+ /**
453
+ * How many paths a command that only makes temp files may print: each segment is a bare `mktemp`, an assignment
454
+ * `name=$(mktemp …)`, or an `echo` of names assigned that way. 0 for any other command.
455
+ */
456
+ export function mktempOnly(command) {
457
+ const assigned = new Set();
458
+ let made = 0;
459
+ for (const segment of splitShell(command)) {
460
+ const alone = MKTEMP_ALONE.exec(segment);
461
+ const assign = alone ? undefined : MKTEMP_ASSIGN.exec(segment);
462
+ const args = alone?.[1] ?? assign?.[3];
463
+ if (args !== undefined) {
464
+ if (/(?:^|\s)(?:-[a-zA-Z]*u[a-zA-Z]*|--dry-run)(?=\s|$)/.test(args))
465
+ return 0;
466
+ if (assign)
467
+ assigned.add(assign[1]);
468
+ made++;
469
+ continue;
470
+ }
471
+ const echo = ECHO_VARS.exec(segment);
472
+ if (!echo || ![...echo[1].matchAll(/\$\{?([A-Za-z_]\w*)/g)].every(name => assigned.has(name[1])))
473
+ return 0;
474
+ }
475
+ return made;
476
+ }
477
+ function printedScratch(command, output, started, birthtime) {
478
+ const created = [];
479
+ const made = mktempOnly(command);
480
+ const lines = output.split("\n").map(line => line.trim()).filter(Boolean);
481
+ const roots = tempRoots();
482
+ for (const path of tempPathsIn(output, roots)) {
483
+ if (path.split("/").includes(".."))
484
+ continue;
485
+ try {
486
+ const real = realpathSync(path);
487
+ if (!tempRootOf(real, roots))
488
+ continue;
489
+ const stats = statSync(real);
490
+ if (!stats.isDirectory() && !stats.isFile())
491
+ continue;
492
+ const birth = birthtime(stats);
493
+ const born = birth > 0 ? Math.floor(birth) > started : made > 0 && lines.length <= made && lines.includes(path);
494
+ if (born)
495
+ created.push(real);
496
+ }
497
+ catch { /* gone or unreadable */ }
498
+ }
499
+ return created;
500
+ }
209
501
  /** Every id exemptRules can legitimately name: the built-in shell rules, the rm-classifier's derived ids, and the
210
502
  * sensitive-path id. Unknown ids (a typo, or a rule that never existed) are inert; this list lets the surface be
211
503
  * reported once instead of discovered when the rule the user meant to silence keeps firing. */
@@ -214,6 +506,7 @@ export const EXEMPTABLE_IDS = [
214
506
  "rm-recursive",
215
507
  "rm-rf",
216
508
  "rm-recursive-dangerous-target",
509
+ "rm-session-scratch",
217
510
  "sensitive-path",
218
511
  ];
219
512
  /** Built-in pattern IDs whose hits become evidence (not level-setters) in evidence mode.
@@ -269,8 +562,10 @@ export function matchPatterns(tool, input, cwd, options) {
269
562
  for (const rule of SHELL_RULES)
270
563
  if (!exempt.has(rule.id) && rule.test.test(command))
271
564
  add({ id: rule.id, severity: rule.severity, label: rule.label });
565
+ // A command that raises privileges anywhere (`sudo`, `doas`, `su -c`, a heredoc fed to `sudo bash`) deletes as someone else: no scratch.
566
+ const scratch = PRIVILEGED.test(command) || !scratchPlatform(options?.platform) ? undefined : options?.scratch;
272
567
  for (const segment of splitShell(command)) {
273
- const hit = classifyRm(segment, cwd);
568
+ const hit = classifyRm(segment, cwd, scratch);
274
569
  // classifyRm derives ids (rm-recursive, rm-rf, rm-recursive-dangerous-target); they are exemptable like any built-in.
275
570
  if (hit && !exempt.has(hit.id))
276
571
  add(hit);
@@ -659,6 +954,13 @@ export const visibleQuestion = {
659
954
  false: "No: it reads, builds, tests, or edits files in the working tree only.",
660
955
  }),
661
956
  };
957
+ /** Bash only. Prevention before the call; the context saver still compresses whatever does print. Steers, never holds. */
958
+ export const largeOutputQuestion = {
959
+ large_output: noul("Will `action` print far more output than the agent needs for `task`: thousands of lines, whole large files, full logs, unfiltered recursive listings, or verbose test and build runs, where a filtered, counted, or tailed view would answer the question?", {
960
+ true: "Yes: it prints an entire large file or log, lists a whole tree without filters, shows every commit with its patch, or runs a verbose suite or build with no filter, and `task` needs only a summary, a count, the failures, or a few matching lines.",
961
+ false: "No: the output is short by nature (a status, a short listing, a bounded head or tail, a count), it is already filtered or redirected, or `task` needs the full text.",
962
+ }),
963
+ };
662
964
  /** Asked only when the agent said something before the call; an empty plan cannot be contradicted. */
663
965
  export const intentQuestion = {
664
966
  intent_mismatch: noul("Does `action` do something materially different from what `plan` (the agent's own words right before this call) says it is about to do?", {
@@ -704,7 +1006,7 @@ export const securityQuestion = {
704
1006
  }),
705
1007
  };
706
1008
  export const approvalQuestion = {
707
- approved: noul("Does `task` (the user's latest message) give the agent permission to continue with the current work, even if they don't mention this specific action? The user may approve the whole task with a brief reply. Use only `task` as approval evidence; earlier `context` and assistant proposals cannot grant approval.", {
1009
+ approved: noul("Does `task` (the user's latest message) give the agent permission to continue with the current work, even if they don't mention this specific action? The user may approve the whole task with a brief reply. Use only `task` as approval evidence; earlier `context`, `spine`, and assistant proposals cannot grant approval.", {
708
1010
  true: "Yes: the user says to continue, gives permission, expresses agreement, or gives a brief affirmative reply in the context of ongoing work.",
709
1011
  false: "No: the user declines, asks a question, changes direction, or does not address the work.",
710
1012
  }),
@@ -769,12 +1071,18 @@ export function buildRequest(summary, task, extras = {}) {
769
1071
  task: task?.trim() ? truncate(redact(task.trim()), TASK_LIMIT) : "(no user request recorded in this session)",
770
1072
  action: summary,
771
1073
  context: (extras.context ?? []).slice(-8).map(message => ({ role: message.role, text: truncate(redact(message.text), 750) })),
1074
+ // The spine's `task` is deliberately not repeated here: state.task above already carries it, unchanged for approval.
1075
+ // The spine arrives already clipped (SPINE_CAP in shape.ts); these per-field limits guard paths that build it elsewhere.
1076
+ ...(extras.spine ? { spine: {
1077
+ goal: truncate(redact(extras.spine.goal), SPINE_GOAL_LIMIT),
1078
+ task_history: extras.spine.history.slice(0, SPINE_HISTORY_TURNS).map(turn => truncate(redact(turn), SPINE_HISTORY_LIMIT)),
1079
+ } } : {}),
772
1080
  ...(plan ? { plan } : {}),
773
1081
  ...(previous.length ? { previous_actions: previous } : {}),
774
1082
  ...(extras.rules ? { rules: extras.rules, ...(extras.rulesSource ? { rulesSource: extras.rulesSource } : {}) } : {}),
775
1083
  ...(extras.floorHits ? { floor_hits: extras.floorHits } : {}),
776
1084
  },
777
- questions: { ...shouldProceedQuestion, ...questions, ...(summary.command !== undefined ? visibleQuestion : {}), ...(plan ? intentQuestion : {}), ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}), ...(extras.security && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary) ? securityQuestion : {}), ...(previous.length ? regretQuestions(previous) : {}), ...violationQuestions, ...(extras.questions ?? {}) },
1085
+ questions: { ...shouldProceedQuestion, ...questions, ...(summary.command !== undefined ? visibleQuestion : {}), ...(plan ? intentQuestion : {}), ...(extras.largeOutput && summary.tool === "bash" ? largeOutputQuestion : {}), ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}), ...(extras.security && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary) ? securityQuestion : {}), ...(previous.length ? regretQuestions(previous) : {}), ...violationQuestions, ...(extras.questions ?? {}) },
778
1086
  };
779
1087
  }
780
1088
  const percent = (value) => value.toFixed(2);
@@ -792,7 +1100,7 @@ export async function evaluateAction(action, options) {
792
1100
  }
793
1101
  const plan = describePlan(action.plan);
794
1102
  const withPlan = (verdict) => (plan ? { ...verdict, plan } : verdict);
795
- const patterns = matchPatterns(action.tool, action.input, action.cwd, { commandRules: config.commandRules, commandDenyRules: config.commandDenyRules, exemptRules: config.exemptRules, pathRules: config.pathRules });
1103
+ const patterns = matchPatterns(action.tool, action.input, action.cwd, { commandRules: config.commandRules, commandDenyRules: config.commandDenyRules, exemptRules: config.exemptRules, pathRules: config.pathRules, scratch: options.scratch });
796
1104
  // Violation pipeline: authorize per-violation, remove authorized from level computation and Jev questions.
797
1105
  const allViolations = patternHitsToViolations(patterns, action.tool, action.input);
798
1106
  const allAuthorizations = allViolations.map(v => authorize(action.task ?? "", v));
@@ -881,7 +1189,7 @@ export async function evaluateAction(action, options) {
881
1189
  // count, so the content sent here is the content the guard judges with.
882
1190
  const resolved = options.rules?.enabled === false ? null : resolveRulesFile(action.cwd, options.rules);
883
1191
  const floorHits = builtInHits.length ? builtInHits.join("; ") : "none";
884
- const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions, plan, questions: options.questions, rules: resolved?.content, rulesSource: resolved?.source, violations: remainingViolations, floorHits });
1192
+ const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions, plan, questions: options.questions, rules: resolved?.content, rulesSource: resolved?.source, violations: remainingViolations, floorHits, spine: action.spine, largeOutput: options.largeOutput?.enabled ?? false });
885
1193
  const result = await ask(judge, request, { timeoutMs: config.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
886
1194
  if (!result.ok) {
887
1195
  if (!config.failOpen) {
@@ -918,6 +1226,8 @@ export async function evaluateAction(action, options) {
918
1226
  judgment.intentMismatch = answers.intent_mismatch.noul;
919
1227
  if (summary.command !== undefined && typeof answers.visible?.noul === "number")
920
1228
  judgment.visible = answers.visible.noul;
1229
+ if (options.largeOutput?.enabled && action.tool === "bash" && typeof answers.large_output?.noul === "number")
1230
+ judgment.largeOutput = answers.large_output.noul;
921
1231
  if (typeof answers.regretted?.noul === "number") {
922
1232
  judgment.regretted = answers.regretted.noul;
923
1233
  if (typeof answers.regret_target?.choice === "string")
@@ -1037,6 +1347,12 @@ export async function evaluateAction(action, options) {
1037
1347
  verdict.offTaskSteer = true;
1038
1348
  if (offTaskTraceOnly)
1039
1349
  verdict.offTaskTraceOnly = true;
1350
+ // Large output steers and never changes the level: the saver compresses what prints, this asks the agent to print less.
1351
+ if (judgment.largeOutput !== undefined && judgment.largeOutput >= options.largeOutput.threshold && view) {
1352
+ const family = commandFamily(view.command);
1353
+ if (family)
1354
+ verdict.largeOutputFamily = family;
1355
+ }
1040
1356
  if (shouldProceedSteer)
1041
1357
  verdict.shouldProceedSteer = true;
1042
1358
  if (shouldProceedTraceOnlyReasonIndex !== undefined) {
@@ -1133,6 +1449,24 @@ export function offTaskSteer(verdict) {
1133
1449
  const score = verdict.judgment?.offTask;
1134
1450
  return `pi-warden: this ${verdict.summary.tool} call looks unrelated to the user's request${score === undefined ? "" : ` (off-task ${percent(score)})`}. It ran. If it serves the request, say how in at most one short sentence; otherwise return to what the user asked for, or ask before widening the work. Do not restate session state or re-answer notices you have already addressed.`;
1135
1451
  }
1452
+ /** What the agent reads the first time a command family is judged noisy: the family and one concrete way to print less. */
1453
+ export function largeOutputSteer(verdict) {
1454
+ const family = verdict.largeOutputFamily ?? verdict.summary.tool;
1455
+ const score = verdict.judgment?.largeOutput;
1456
+ const log = `/tmp/warden-${family.replace(/[^A-Za-z0-9]+/g, "-")}.log`;
1457
+ return [
1458
+ `pi-warden: \`${family}\` commands may print far more than you need${score === undefined ? "" : ` (large-output ${percent(score)})`}. This one runs unchanged.`,
1459
+ `Next time, redirect and show the tail: \`${family} … > ${log} 2>&1; tail -40 ${log}\`.`,
1460
+ ].join("\n");
1461
+ }
1462
+ /** The large-output steer for this verdict, once per command family per session; `steered` is the session's memory. */
1463
+ export function largeOutputNotice(verdict, steered) {
1464
+ const family = verdict.largeOutputFamily;
1465
+ if (!family || steered.has(family))
1466
+ return undefined;
1467
+ steered.add(family);
1468
+ return largeOutputSteer(verdict);
1469
+ }
1136
1470
  /** Offline stand-in for the approval question when TypeSafe is not available. */
1137
1471
  export function textApproves(task) {
1138
1472
  return /\b(?:yes|yep|yeah|go ahead|do it|proceed|approved?|confirm(?:ed)?|ok(?:ay)?|sure|please do|run it)\b/i.test(task ?? "") && !/\b(?:no|don't|do not|stop|wait|instead|not)\b/i.test(task ?? "");
@@ -1166,6 +1500,7 @@ const ACTION_VERBS = {
1166
1500
  "rm-recursive": ["delete", "remove", "clean", "tidy", "purge"],
1167
1501
  "rm-rf": ["delete", "remove", "clean", "tidy", "purge"],
1168
1502
  "rm-recursive-dangerous-target": ["delete", "remove", "clean", "tidy", "purge"],
1503
+ "rm-session-scratch": ["delete", "remove", "clean", "tidy", "purge"],
1169
1504
  "find-delete": ["delete", "remove", "clean", "tidy", "purge"],
1170
1505
  "git-rm": ["delete", "remove", "clean", "tidy", "purge"],
1171
1506
  "publish": ["publish"],
@@ -1265,7 +1600,7 @@ export function isAuthEligible(severity) {
1265
1600
  // Pattern-detected risky/destructive hits are auth-eligible (user can explicitly authorize)
1266
1601
  return true;
1267
1602
  }
1268
- const RM_FAMILY_IDS = new Set(["rm", "rm-recursive", "rm-rf", "rm-recursive-dangerous-target", "find-delete"]);
1603
+ const RM_FAMILY_IDS = new Set(["rm", "rm-recursive", "rm-rf", "rm-recursive-dangerous-target", "rm-session-scratch", "find-delete"]);
1269
1604
  const RM_COMMAND_RE = /(?:^|[\s"'(])rm\s+(.*)$/i;
1270
1605
  /** Extract file targets from every rm segment in a command. */
1271
1606
  function detectRmTargets(command) {
package/dist/holds.d.ts CHANGED
@@ -130,6 +130,8 @@ export declare function textRegrets(prompt: string | undefined): boolean;
130
130
  export declare function outcomeNote(record: CallRecord): string;
131
131
  /** One line for /warden status. */
132
132
  export declare function formatHolds(snapshot: HoldSnapshot, logPath?: string): string;
133
+ /** The session id as a file name part: no separators or dots, so it cannot leave the directory it is joined to. */
134
+ export declare function sessionFileId(sessionId: string): string;
133
135
  /** Per-session file beside the user config: `<agent dir>/pi-warden/holds/<date>-<session>.jsonl`. */
134
136
  export declare function holdLogPath(sessionId: string, at?: Date): string;
135
137
  /** Rewrites the session's records as JSON lines, owner-only, one write at a time so outcomes never interleave. */
package/dist/holds.js CHANGED
@@ -204,11 +204,14 @@ export function formatHolds(snapshot, logPath) {
204
204
  parts.push(`${snapshot.allowed} allowed (${snapshot.regretted} regretted by you, ${snapshot.accepted} accepted)`);
205
205
  return `Holds: ${parts.join("; ")}.${logPath ? ` Log: ${logPath}.` : ""}`;
206
206
  }
207
+ /** The session id as a file name part: no separators or dots, so it cannot leave the directory it is joined to. */
208
+ export function sessionFileId(sessionId) {
209
+ return sessionId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || String(process.pid);
210
+ }
207
211
  /** Per-session file beside the user config: `<agent dir>/pi-warden/holds/<date>-<session>.jsonl`. */
208
212
  export function holdLogPath(sessionId, at = new Date()) {
209
213
  const day = at.toISOString().slice(0, 10);
210
- const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || String(process.pid);
211
- return join(dirname(userConfigPath()), "holds", `${day}-${safe}.jsonl`);
214
+ return join(dirname(userConfigPath()), "holds", `${day}-${sessionFileId(sessionId)}.jsonl`);
212
215
  }
213
216
  /** Rewrites the session's records as JSON lines, owner-only, one write at a time so outcomes never interleave. */
214
217
  export class HoldLog {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, formatVerdictTokens, steerReason, intentSteer, offTaskSteer, shouldProceedMessage, steerFingerprint, SteerRepeatWindow, questions, intentQuestion, visibleQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions, authorize, isNegated, scopeMatches, isAuthEligible, patternHitsToViolations, escalateBlastRadius, escalateRulesViolation, aggregateLevel, removeAuthorized, scopeFromInput, matchPathRules, parseViolationJudgments } from "./guard.js";
1
+ export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, formatVerdictTokens, steerReason, intentSteer, offTaskSteer, largeOutputSteer, largeOutputNotice, largeOutputQuestion, commandFamily, shouldProceedMessage, steerFingerprint, SteerRepeatWindow, questions, intentQuestion, visibleQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions, authorize, isNegated, scopeMatches, isAuthEligible, patternHitsToViolations, escalateBlastRadius, escalateRulesViolation, aggregateLevel, removeAuthorized, scopeFromInput, matchPathRules, parseViolationJudgments } from "./guard.js";
2
2
  export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, PreviousAction, ScannedCommand, ScopeLabel, Severity, Verdict, Violation, ViolationSource, ViolationScope, Authorization, EscalatedViolation, ViolationJudgmentAnswer } from "./guard.js";
3
3
  export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
4
4
  export { ActionGuard } from "./action-guard.js";
@@ -29,7 +29,7 @@ export type { CallOutcome, CallRecord, CallScores, HoldSnapshot, OutcomeVia } fr
29
29
  export type { ContextLedgerSnapshot, RecallKind } from "./saver.js";
30
30
  export type { ShapeResult } from "./shape.js";
31
31
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, setNestedValue, getNestedValue, parseConfigValue, PACKAGE_NAME } from "./config.js";
32
- export type { ActionGuardConfig, OffTaskThreshold, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, RulesConfig, ContextConfig, RunawayConfig, NotifyConfig, SubagentConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
32
+ export type { ActionGuardConfig, OffTaskThreshold, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, RulesConfig, ContextConfig, LargeOutputConfig, RunawayConfig, NotifyConfig, SubagentConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
33
33
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
34
34
  export type { CommandView } from "./tools.js";
35
35
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, SENTENCE_TEMPLATES, pickSentenceTemplate, TOKEN_NAMES } from "./widget.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, formatVerdictTokens, steerReason, intentSteer, offTaskSteer, shouldProceedMessage, steerFingerprint, SteerRepeatWindow, questions, intentQuestion, visibleQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions, authorize, isNegated, scopeMatches, isAuthEligible, patternHitsToViolations, escalateBlastRadius, escalateRulesViolation, aggregateLevel, removeAuthorized, scopeFromInput, matchPathRules, parseViolationJudgments } from "./guard.js";
1
+ export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, formatVerdictTokens, steerReason, intentSteer, offTaskSteer, largeOutputSteer, largeOutputNotice, largeOutputQuestion, commandFamily, shouldProceedMessage, steerFingerprint, SteerRepeatWindow, questions, intentQuestion, visibleQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions, authorize, isNegated, scopeMatches, isAuthEligible, patternHitsToViolations, escalateBlastRadius, escalateRulesViolation, aggregateLevel, removeAuthorized, scopeFromInput, matchPathRules, parseViolationJudgments } 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";
@@ -0,0 +1,44 @@
1
+ /** A failed judge request, as a category safe to name (never an exception body). */
2
+ export type JudgeFailureKind = "timeout" | "network" | "auth" | "configuration" | "other";
3
+ export interface JudgeCooldownSettings {
4
+ /** Consecutive transient failures that start the cooldown. */
5
+ failuresBeforeCooldown: number;
6
+ /** How long the judge is left alone once the cooldown starts. */
7
+ cooldownMs: number;
8
+ }
9
+ /** Classify a judge error into a safe category (spec §6: never exception bodies). */
10
+ export declare function classifyJudgeError(err: unknown): JudgeFailureKind;
11
+ /**
12
+ * What a failure says about the backend, or `undefined` when it says nothing.
13
+ *
14
+ * `budget` already stops judgments for the session on its own. `validation` is thrown locally for one malformed
15
+ * request (too many questions, input over the byte limit) and says nothing about the backend. `aborted` is ambiguous: pi-warden's own per-request
16
+ * deadline reaches the SDK as an abort, so a hung backend surfaces as `aborted`, not `timeout`. The signal's reason
17
+ * tells the two apart; only a user's cancel is left out.
18
+ */
19
+ export declare function cooldownFailureKind(err: unknown, signal?: AbortSignal): JudgeFailureKind | undefined;
20
+ export type CooldownEvent = {
21
+ type: "started";
22
+ kind: JudgeFailureKind;
23
+ ms: number;
24
+ } | {
25
+ type: "recovered";
26
+ };
27
+ /**
28
+ * Session-scoped pause on a failing judge, so a dead backend costs one notice instead of one timeout per action.
29
+ * Nothing is persisted; a new session starts with the judge trusted again.
30
+ */
31
+ export declare class JudgeCooldown {
32
+ private readonly now;
33
+ private failures;
34
+ private until;
35
+ /** A window has started and no judgment has succeeded since, so the user was told and not yet told it recovered. */
36
+ private cooled;
37
+ constructor(now?: () => number);
38
+ /** True while judgments are paused. */
39
+ active(): boolean;
40
+ failure(kind: JudgeFailureKind, settings: JudgeCooldownSettings): CooldownEvent | undefined;
41
+ success(): CooldownEvent | undefined;
42
+ reset(): void;
43
+ private open;
44
+ }