pi-warden 0.40.2 → 0.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +23 -3
- package/dist/action-guard.d.ts +11 -2
- package/dist/action-guard.js +1 -1
- package/dist/backend.d.ts +4 -0
- package/dist/backend.js +4 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +15 -1
- package/dist/conscience.d.ts +9 -3
- package/dist/conscience.js +13 -4
- package/dist/extension.d.ts +17 -4
- package/dist/extension.js +311 -80
- package/dist/extension.js.bak +263 -76
- package/dist/guard.d.ts +115 -1
- package/dist/guard.js +404 -11
- package/dist/holds.d.ts +2 -0
- package/dist/holds.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/judge-cooldown.d.ts +44 -0
- package/dist/judge-cooldown.js +92 -0
- package/dist/panel.d.ts +5 -2
- package/dist/panel.js +5 -2
- package/dist/redact.d.ts +5 -3
- package/dist/redact.js +17 -4
- package/dist/rules.d.ts +2 -0
- package/dist/rules.js +23 -0
- package/dist/shape.d.ts +46 -0
- package/dist/shape.js +76 -1
- package/dist/trace-file.d.ts +50 -0
- package/dist/trace-file.js +141 -0
- package/dist/trace.d.ts +12 -1
- package/dist/trace.js +3 -3
- package/package.json +1 -1
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,58 @@ 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
|
+
}
|
|
103
|
+
/** Subcommands whose effect others see: history or remote state changes, a pull request, a release, a published package. */
|
|
104
|
+
const VISIBLE_SUBCOMMANDS = {
|
|
105
|
+
git: new Set(["push", "commit", "merge", "tag", "reset"]),
|
|
106
|
+
gh: new Set(["pr", "release"]),
|
|
107
|
+
npm: new Set(["publish"]),
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Whether a shell command has a segment whose effect is visible outside the working tree, decided in code before any
|
|
111
|
+
* request. Quoted data such as a commit message is blanked first, so a message that mentions `git push` is not a push.
|
|
112
|
+
*/
|
|
113
|
+
export function isVisibleCommand(command) {
|
|
114
|
+
// A substitution or subshell runs its command too: `out=$(git push …)`, `(cd repo && git push)`.
|
|
115
|
+
return splitShell(stripDataText(command).text).flatMap(segment => segment.split(/\$\(|`|\(/)).some(segment => {
|
|
116
|
+
const head = headOf(segment);
|
|
117
|
+
const subcommands = head ? VISIBLE_SUBCOMMANDS[head] : undefined;
|
|
118
|
+
if (!head || !subcommands)
|
|
119
|
+
return false;
|
|
120
|
+
const tokens = segment.trim().split(/\s+/);
|
|
121
|
+
// `git -C dir push`, `git -c key=value commit`: the option value is not the subcommand.
|
|
122
|
+
for (let index = tokens.findIndex(token => token.replace(/^.*\//, "") === head) + 1; index < tokens.length; index++) {
|
|
123
|
+
const token = tokens[index];
|
|
124
|
+
if (token === "-C" || token === "-c" || token === "-R" || token === "--repo") {
|
|
125
|
+
index++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (token.startsWith("-"))
|
|
129
|
+
continue;
|
|
130
|
+
return subcommands.has(token.replace(/[)`]+$/, ""));
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
82
135
|
/**
|
|
83
136
|
* Quoted strings replaced by a placeholder; escapes inside double quotes are honoured, single quotes take everything.
|
|
84
137
|
* A double-quoted string that substitutes a command (`"$(...)"`, backticks) executes it, so that string stays visible.
|
|
@@ -177,7 +230,7 @@ export function stripDataText(command) {
|
|
|
177
230
|
* rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are
|
|
178
231
|
* 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
232
|
*/
|
|
180
|
-
function classifyRm(segment, cwd) {
|
|
233
|
+
function classifyRm(segment, cwd, scratch) {
|
|
181
234
|
const match = /(?:^|[\s"'(])rm\s+(.*)$/.exec(segment);
|
|
182
235
|
if (!match)
|
|
183
236
|
return undefined;
|
|
@@ -196,6 +249,10 @@ function classifyRm(segment, cwd) {
|
|
|
196
249
|
return cwd ? !isInside(clean, cwd) : true;
|
|
197
250
|
return clean.split(/[\\/]/).includes("..");
|
|
198
251
|
});
|
|
252
|
+
const budget = scratchBudget();
|
|
253
|
+
if (dangerousTarget && scratch?.size && targets.length && targets.every(target => isSessionScratch(target, scratch, budget))) {
|
|
254
|
+
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" };
|
|
255
|
+
}
|
|
199
256
|
if (dangerousTarget)
|
|
200
257
|
return { id: "rm-recursive-dangerous-target", severity: "destructive", label: "recursive rm on an absolute, home, variable, or parent path" };
|
|
201
258
|
if (force)
|
|
@@ -206,6 +263,297 @@ function isInside(target, cwd) {
|
|
|
206
263
|
const rel = relative(resolve(cwd), resolve(target));
|
|
207
264
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
208
265
|
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Session scratch: paths the agent created under the OS temp directory in this session. A recursive rm whose every
|
|
268
|
+
// target is such a path, after symlinks are resolved, is risky rather than destructive. Everything here fails closed:
|
|
269
|
+
// a path that cannot be resolved, or that uses shell expansion, is not scratch.
|
|
270
|
+
/**
|
|
271
|
+
* Whether the exemption applies on this platform. It rests on birth time being a real creation time: macOS and Windows
|
|
272
|
+
* report one; Linux without `statx` reports ctime, which `mv` updates, so moved-in content would pass the tree walk.
|
|
273
|
+
* Elsewhere nothing is recorded and a recursive rm is classified as if no scratch existed.
|
|
274
|
+
*/
|
|
275
|
+
export function scratchPlatform(platform = process.platform) {
|
|
276
|
+
return platform === "darwin" || platform === "win32";
|
|
277
|
+
}
|
|
278
|
+
/** The identity of a path itself (a symlink is not followed); undefined when it does not exist. */
|
|
279
|
+
export function scratchIdentity(path) {
|
|
280
|
+
try {
|
|
281
|
+
const stats = lstatSync(path);
|
|
282
|
+
return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs };
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function sameIdentity(a, b) {
|
|
289
|
+
return a !== undefined && a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
290
|
+
}
|
|
291
|
+
/** Drops records whose path is gone or now names a different file, so a later file at the same path is not scratch. */
|
|
292
|
+
export function pruneScratch(records) {
|
|
293
|
+
for (const [path, identity] of records)
|
|
294
|
+
if (!sameIdentity(scratchIdentity(path), identity))
|
|
295
|
+
records.delete(path);
|
|
296
|
+
}
|
|
297
|
+
/** Real paths of the temp roots that exist: `os.tmpdir()`, `$TMPDIR`, `/tmp`, `/private/tmp`. */
|
|
298
|
+
export function tempRoots() {
|
|
299
|
+
const roots = new Set();
|
|
300
|
+
for (const root of [tmpdir(), process.env.TMPDIR, "/tmp", "/private/tmp"]) {
|
|
301
|
+
if (!root || !isAbsolute(root))
|
|
302
|
+
continue;
|
|
303
|
+
try {
|
|
304
|
+
roots.add(realpathSync(root));
|
|
305
|
+
}
|
|
306
|
+
catch { /* absent on this system */ }
|
|
307
|
+
}
|
|
308
|
+
return [...roots];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The real path of an absolute path: the realpath of its deepest existing ancestor plus the missing rest. Undefined when
|
|
312
|
+
* a component exists but cannot be resolved (a dangling symlink, a loop, no permission).
|
|
313
|
+
*/
|
|
314
|
+
export function realTarget(path) {
|
|
315
|
+
const rest = [];
|
|
316
|
+
let head = resolve(path);
|
|
317
|
+
for (;;) {
|
|
318
|
+
try {
|
|
319
|
+
return join(realpathSync(head), ...rest.reverse());
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
const code = error.code;
|
|
323
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
324
|
+
return undefined;
|
|
325
|
+
try {
|
|
326
|
+
lstatSync(head);
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
catch { /* missing: resolve the parent */ }
|
|
330
|
+
const parent = dirname(head);
|
|
331
|
+
if (parent === head)
|
|
332
|
+
return undefined;
|
|
333
|
+
rest.push(basename(head));
|
|
334
|
+
head = parent;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Host paths: directories outside the project where the host lets its agent write, from `PI_WARDEN_HOST_PATHS` only (a
|
|
340
|
+
* `:`-separated list). Relative entries, empty entries, and a filesystem root are ignored. Each entry is kept as its real
|
|
341
|
+
* path, so a target is compared after `..` and symlinks are resolved on both sides.
|
|
342
|
+
*/
|
|
343
|
+
export const HOST_PATHS_ENV = "PI_WARDEN_HOST_PATHS";
|
|
344
|
+
export function hostPaths(env = process.env) {
|
|
345
|
+
const roots = new Set();
|
|
346
|
+
for (const entry of (env[HOST_PATHS_ENV] ?? "").split(":")) {
|
|
347
|
+
if (!entry || !isAbsolute(entry))
|
|
348
|
+
continue;
|
|
349
|
+
const real = realTarget(entry);
|
|
350
|
+
if (real && dirname(real) !== real)
|
|
351
|
+
roots.add(real);
|
|
352
|
+
}
|
|
353
|
+
return [...roots];
|
|
354
|
+
}
|
|
355
|
+
/** Whether a target lies in a host path, after `..` and symlinks are resolved. An unresolvable target is not in one. */
|
|
356
|
+
function inHostPath(target, roots) {
|
|
357
|
+
if (!roots.length)
|
|
358
|
+
return false;
|
|
359
|
+
const real = realTarget(target);
|
|
360
|
+
return real !== undefined && roots.some(root => real === root || real.startsWith(root + sep));
|
|
361
|
+
}
|
|
362
|
+
/** The temp root a real path lies strictly under; a temp root itself has none. */
|
|
363
|
+
export function tempRootOf(real, roots = tempRoots()) {
|
|
364
|
+
return roots.find(root => real !== root && real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
365
|
+
}
|
|
366
|
+
/** A privilege-raising command word anywhere in a command. */
|
|
367
|
+
const PRIVILEGED = /(?:^|[\s;&|("'`])(?:sudo|doas|su|pkexec|run0)(?=\s|$)/m;
|
|
368
|
+
/** A literal absolute path: no quotes left inside, no glob, brace, tilde, variable, escape, or substitution. */
|
|
369
|
+
const LITERAL_PATH = /^\/[^*?[\]{}$`~\\"'\s]*$/;
|
|
370
|
+
/**
|
|
371
|
+
* 10,000 entries covers a generated test tree of a few thousand directories with room to spare, and 200 ms keeps the
|
|
372
|
+
* synchronous walk in the tool-call hook short. A tree that needs more is not checked, so it keeps the hold.
|
|
373
|
+
*/
|
|
374
|
+
const scratchBudget = () => ({ entries: 10_000, deadline: Date.now() + 200 });
|
|
375
|
+
/**
|
|
376
|
+
* True when every entry of the tree at `path`, the root included, has a birth time at or after `born`. Symlinks are
|
|
377
|
+
* checked as links and never followed. False when the root is missing, an entry has no birth time or cannot be read,
|
|
378
|
+
* or the walk runs past its budget: content moved in from elsewhere keeps its older birth time and must not pass.
|
|
379
|
+
*/
|
|
380
|
+
export function bornAfter(path, born, budget = scratchBudget()) {
|
|
381
|
+
if (!(born > 0))
|
|
382
|
+
return false;
|
|
383
|
+
const stack = [path];
|
|
384
|
+
while (stack.length) {
|
|
385
|
+
if (--budget.entries < 0 || Date.now() > budget.deadline)
|
|
386
|
+
return false;
|
|
387
|
+
const current = stack.pop();
|
|
388
|
+
try {
|
|
389
|
+
const stats = lstatSync(current);
|
|
390
|
+
if (!(stats.birthtimeMs > 0) || stats.birthtimeMs < born)
|
|
391
|
+
return false;
|
|
392
|
+
if (stats.isDirectory())
|
|
393
|
+
for (const name of readdirSync(current))
|
|
394
|
+
stack.push(join(current, name));
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
function isSessionScratch(target, scratch, budget) {
|
|
403
|
+
const clean = target.replace(/^["']|["']$/g, "");
|
|
404
|
+
if (!LITERAL_PATH.test(clean) || clean.split("/").includes(".."))
|
|
405
|
+
return false;
|
|
406
|
+
const real = realTarget(clean);
|
|
407
|
+
const root = real === undefined ? undefined : tempRootOf(real);
|
|
408
|
+
if (!real || !root)
|
|
409
|
+
return false;
|
|
410
|
+
for (let path = real; path !== root && path.startsWith(root); path = dirname(path)) {
|
|
411
|
+
const recorded = scratch.get(path);
|
|
412
|
+
if (recorded)
|
|
413
|
+
return sameIdentity(scratchIdentity(path), recorded) && bornAfter(real, recorded.birthtimeMs, budget);
|
|
414
|
+
}
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
/** `mkdir` targets in a command that are literal absolute paths; flags and relative operands are skipped. */
|
|
418
|
+
function mkdirTargets(command) {
|
|
419
|
+
const targets = [];
|
|
420
|
+
for (const segment of splitShell(stripDataText(command).text)) {
|
|
421
|
+
if (headOf(segment) !== "mkdir")
|
|
422
|
+
continue;
|
|
423
|
+
const tokens = segment.trim().split(/\s+/);
|
|
424
|
+
for (const token of tokens.slice(tokens.findIndex(token => token.replace(/^.*\//, "") === "mkdir") + 1)) {
|
|
425
|
+
const clean = token.replace(/^["']|["']$/g, "");
|
|
426
|
+
if (LITERAL_PATH.test(clean) && !clean.split("/").includes(".."))
|
|
427
|
+
targets.push(clean);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return targets;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Real paths under a temp root that this call may create and that do not exist yet: `mkdir` targets of a shell command,
|
|
434
|
+
* or a written file. Each path and its missing ancestors below the temp root are listed. Taken before the call runs, so
|
|
435
|
+
* a directory that already existed is never recorded as created.
|
|
436
|
+
*/
|
|
437
|
+
export function scratchCandidates(tool, input, cwd, platform = process.platform) {
|
|
438
|
+
if (!scratchPlatform(platform))
|
|
439
|
+
return [];
|
|
440
|
+
const command = tool === "bash" ? commandOf(tool, input)?.command : undefined;
|
|
441
|
+
const paths = command ? mkdirTargets(command) : tool === "write" && typeof input.path === "string" && !input.path.startsWith("~") ? [resolve(cwd, input.path)] : [];
|
|
442
|
+
const roots = tempRoots();
|
|
443
|
+
const missing = new Set();
|
|
444
|
+
for (const path of paths) {
|
|
445
|
+
const real = realTarget(path);
|
|
446
|
+
const root = real === undefined ? undefined : tempRootOf(real, roots);
|
|
447
|
+
if (!real || !root)
|
|
448
|
+
continue;
|
|
449
|
+
for (let current = real; current !== root && !existsSync(current); current = dirname(current))
|
|
450
|
+
missing.add(current);
|
|
451
|
+
}
|
|
452
|
+
return [...missing];
|
|
453
|
+
}
|
|
454
|
+
/** At most this many temp paths are read from one tool result. */
|
|
455
|
+
const SCRATCH_OUTPUT_LIMIT = 20;
|
|
456
|
+
/** Absolute paths in text that start with a temp root, as spelled or as resolved. */
|
|
457
|
+
function tempPathsIn(text, roots) {
|
|
458
|
+
const prefixes = new Set(roots);
|
|
459
|
+
for (const root of [tmpdir(), process.env.TMPDIR, "/tmp", "/private/tmp"])
|
|
460
|
+
if (root && isAbsolute(root))
|
|
461
|
+
prefixes.add(root.replace(/\/+$/, ""));
|
|
462
|
+
const alternatives = [...prefixes].map(prefix => prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
463
|
+
const pattern = new RegExp(`(?:^|[\\s"'=:(\\[])((?:${alternatives})/[^\\s"'\`:,;()[\\]{}<>*?$\\\\]+)`, "gm");
|
|
464
|
+
const found = new Set();
|
|
465
|
+
for (const match of text.matchAll(pattern)) {
|
|
466
|
+
found.add(match[1].replace(/[./]+$/, ""));
|
|
467
|
+
if (found.size >= SCRATCH_OUTPUT_LIMIT)
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
return [...found];
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Real paths the call created, read after it ran, each with its identity. A candidate from `scratchCandidates` counts
|
|
474
|
+
* when it now exists as itself (not through a symlink). For a shell command, absolute temp paths printed in its output
|
|
475
|
+
* also count, bounded: the first 20 such paths only, each must exist under a temp root as a real directory or file, and
|
|
476
|
+
* its birth time must fall in a later millisecond than `started` (`Date.now()` when the call began). Where the file
|
|
477
|
+
* system reports no birth time, a printed path counts only from a command that does nothing but run `mktemp` and print
|
|
478
|
+
* what it made (`mktempOnly`). `birthtime` reads a file's birth time in milliseconds, 0 when the file system has none.
|
|
479
|
+
* Nothing is recorded where `scratchPlatform` is false.
|
|
480
|
+
*/
|
|
481
|
+
export function createdScratch(tool, input, output, started, candidates, birthtime = (stats) => stats.birthtimeMs, platform = process.platform) {
|
|
482
|
+
if (!scratchPlatform(platform))
|
|
483
|
+
return new Map();
|
|
484
|
+
const found = [];
|
|
485
|
+
for (const path of candidates) {
|
|
486
|
+
try {
|
|
487
|
+
if (realpathSync(path) === path)
|
|
488
|
+
found.push(path);
|
|
489
|
+
}
|
|
490
|
+
catch { /* not created */ }
|
|
491
|
+
}
|
|
492
|
+
const command = tool === "bash" ? commandOf(tool, input)?.command : undefined;
|
|
493
|
+
if (command)
|
|
494
|
+
found.push(...printedScratch(command, output, started, birthtime));
|
|
495
|
+
const created = new Map();
|
|
496
|
+
for (const path of found) {
|
|
497
|
+
const identity = scratchIdentity(path);
|
|
498
|
+
if (identity)
|
|
499
|
+
created.set(path, identity);
|
|
500
|
+
}
|
|
501
|
+
return created;
|
|
502
|
+
}
|
|
503
|
+
/** Arguments of a `mktemp` that creates something: literal words only, and no `-u`/`--dry-run`. */
|
|
504
|
+
const MKTEMP_ARGS = String.raw `((?:\s+[^\s$\`<>()"']+)*)`;
|
|
505
|
+
const MKTEMP_ALONE = new RegExp(String.raw `^mktemp${MKTEMP_ARGS}$`);
|
|
506
|
+
const MKTEMP_ASSIGN = new RegExp(String.raw `^([A-Za-z_]\w*)=("?)\$\(\s*mktemp${MKTEMP_ARGS}\s*\)\2$`);
|
|
507
|
+
const ECHO_VARS = /^echo((?:\s+"?\$\{?[A-Za-z_]\w*\}?"?)+)$/;
|
|
508
|
+
/**
|
|
509
|
+
* How many paths a command that only makes temp files may print: each segment is a bare `mktemp`, an assignment
|
|
510
|
+
* `name=$(mktemp …)`, or an `echo` of names assigned that way. 0 for any other command.
|
|
511
|
+
*/
|
|
512
|
+
export function mktempOnly(command) {
|
|
513
|
+
const assigned = new Set();
|
|
514
|
+
let made = 0;
|
|
515
|
+
for (const segment of splitShell(command)) {
|
|
516
|
+
const alone = MKTEMP_ALONE.exec(segment);
|
|
517
|
+
const assign = alone ? undefined : MKTEMP_ASSIGN.exec(segment);
|
|
518
|
+
const args = alone?.[1] ?? assign?.[3];
|
|
519
|
+
if (args !== undefined) {
|
|
520
|
+
if (/(?:^|\s)(?:-[a-zA-Z]*u[a-zA-Z]*|--dry-run)(?=\s|$)/.test(args))
|
|
521
|
+
return 0;
|
|
522
|
+
if (assign)
|
|
523
|
+
assigned.add(assign[1]);
|
|
524
|
+
made++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const echo = ECHO_VARS.exec(segment);
|
|
528
|
+
if (!echo || ![...echo[1].matchAll(/\$\{?([A-Za-z_]\w*)/g)].every(name => assigned.has(name[1])))
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
return made;
|
|
532
|
+
}
|
|
533
|
+
function printedScratch(command, output, started, birthtime) {
|
|
534
|
+
const created = [];
|
|
535
|
+
const made = mktempOnly(command);
|
|
536
|
+
const lines = output.split("\n").map(line => line.trim()).filter(Boolean);
|
|
537
|
+
const roots = tempRoots();
|
|
538
|
+
for (const path of tempPathsIn(output, roots)) {
|
|
539
|
+
if (path.split("/").includes(".."))
|
|
540
|
+
continue;
|
|
541
|
+
try {
|
|
542
|
+
const real = realpathSync(path);
|
|
543
|
+
if (!tempRootOf(real, roots))
|
|
544
|
+
continue;
|
|
545
|
+
const stats = statSync(real);
|
|
546
|
+
if (!stats.isDirectory() && !stats.isFile())
|
|
547
|
+
continue;
|
|
548
|
+
const birth = birthtime(stats);
|
|
549
|
+
const born = birth > 0 ? Math.floor(birth) > started : made > 0 && lines.length <= made && lines.includes(path);
|
|
550
|
+
if (born)
|
|
551
|
+
created.push(real);
|
|
552
|
+
}
|
|
553
|
+
catch { /* gone or unreadable */ }
|
|
554
|
+
}
|
|
555
|
+
return created;
|
|
556
|
+
}
|
|
209
557
|
/** Every id exemptRules can legitimately name: the built-in shell rules, the rm-classifier's derived ids, and the
|
|
210
558
|
* sensitive-path id. Unknown ids (a typo, or a rule that never existed) are inert; this list lets the surface be
|
|
211
559
|
* reported once instead of discovered when the rule the user meant to silence keeps firing. */
|
|
@@ -214,6 +562,7 @@ export const EXEMPTABLE_IDS = [
|
|
|
214
562
|
"rm-recursive",
|
|
215
563
|
"rm-rf",
|
|
216
564
|
"rm-recursive-dangerous-target",
|
|
565
|
+
"rm-session-scratch",
|
|
217
566
|
"sensitive-path",
|
|
218
567
|
];
|
|
219
568
|
/** Built-in pattern IDs whose hits become evidence (not level-setters) in evidence mode.
|
|
@@ -269,8 +618,10 @@ export function matchPatterns(tool, input, cwd, options) {
|
|
|
269
618
|
for (const rule of SHELL_RULES)
|
|
270
619
|
if (!exempt.has(rule.id) && rule.test.test(command))
|
|
271
620
|
add({ id: rule.id, severity: rule.severity, label: rule.label });
|
|
621
|
+
// A command that raises privileges anywhere (`sudo`, `doas`, `su -c`, a heredoc fed to `sudo bash`) deletes as someone else: no scratch.
|
|
622
|
+
const scratch = PRIVILEGED.test(command) || !scratchPlatform(options?.platform) ? undefined : options?.scratch;
|
|
272
623
|
for (const segment of splitShell(command)) {
|
|
273
|
-
const hit = classifyRm(segment, cwd);
|
|
624
|
+
const hit = classifyRm(segment, cwd, scratch);
|
|
274
625
|
// classifyRm derives ids (rm-recursive, rm-rf, rm-recursive-dangerous-target); they are exemptable like any built-in.
|
|
275
626
|
if (hit && !exempt.has(hit.id))
|
|
276
627
|
add(hit);
|
|
@@ -659,6 +1010,13 @@ export const visibleQuestion = {
|
|
|
659
1010
|
false: "No: it reads, builds, tests, or edits files in the working tree only.",
|
|
660
1011
|
}),
|
|
661
1012
|
};
|
|
1013
|
+
/** Bash only. Prevention before the call; the context saver still compresses whatever does print. Steers, never holds. */
|
|
1014
|
+
export const largeOutputQuestion = {
|
|
1015
|
+
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?", {
|
|
1016
|
+
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.",
|
|
1017
|
+
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.",
|
|
1018
|
+
}),
|
|
1019
|
+
};
|
|
662
1020
|
/** Asked only when the agent said something before the call; an empty plan cannot be contradicted. */
|
|
663
1021
|
export const intentQuestion = {
|
|
664
1022
|
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 +1062,7 @@ export const securityQuestion = {
|
|
|
704
1062
|
}),
|
|
705
1063
|
};
|
|
706
1064
|
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.", {
|
|
1065
|
+
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
1066
|
true: "Yes: the user says to continue, gives permission, expresses agreement, or gives a brief affirmative reply in the context of ongoing work.",
|
|
709
1067
|
false: "No: the user declines, asks a question, changes direction, or does not address the work.",
|
|
710
1068
|
}),
|
|
@@ -769,12 +1127,18 @@ export function buildRequest(summary, task, extras = {}) {
|
|
|
769
1127
|
task: task?.trim() ? truncate(redact(task.trim()), TASK_LIMIT) : "(no user request recorded in this session)",
|
|
770
1128
|
action: summary,
|
|
771
1129
|
context: (extras.context ?? []).slice(-8).map(message => ({ role: message.role, text: truncate(redact(message.text), 750) })),
|
|
1130
|
+
// The spine's `task` is deliberately not repeated here: state.task above already carries it, unchanged for approval.
|
|
1131
|
+
// The spine arrives already clipped (SPINE_CAP in shape.ts); these per-field limits guard paths that build it elsewhere.
|
|
1132
|
+
...(extras.spine ? { spine: {
|
|
1133
|
+
goal: truncate(redact(extras.spine.goal), SPINE_GOAL_LIMIT),
|
|
1134
|
+
task_history: extras.spine.history.slice(0, SPINE_HISTORY_TURNS).map(turn => truncate(redact(turn), SPINE_HISTORY_LIMIT)),
|
|
1135
|
+
} } : {}),
|
|
772
1136
|
...(plan ? { plan } : {}),
|
|
773
1137
|
...(previous.length ? { previous_actions: previous } : {}),
|
|
774
1138
|
...(extras.rules ? { rules: extras.rules, ...(extras.rulesSource ? { rulesSource: extras.rulesSource } : {}) } : {}),
|
|
775
1139
|
...(extras.floorHits ? { floor_hits: extras.floorHits } : {}),
|
|
776
1140
|
},
|
|
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 ?? {}) },
|
|
1141
|
+
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
1142
|
};
|
|
779
1143
|
}
|
|
780
1144
|
const percent = (value) => value.toFixed(2);
|
|
@@ -792,7 +1156,7 @@ export async function evaluateAction(action, options) {
|
|
|
792
1156
|
}
|
|
793
1157
|
const plan = describePlan(action.plan);
|
|
794
1158
|
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 });
|
|
1159
|
+
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
1160
|
// Violation pipeline: authorize per-violation, remove authorized from level computation and Jev questions.
|
|
797
1161
|
const allViolations = patternHitsToViolations(patterns, action.tool, action.input);
|
|
798
1162
|
const allAuthorizations = allViolations.map(v => authorize(action.task ?? "", v));
|
|
@@ -845,7 +1209,9 @@ export async function evaluateAction(action, options) {
|
|
|
845
1209
|
reasons.push(`${hit.severity}: ${hit.label}`);
|
|
846
1210
|
}
|
|
847
1211
|
}
|
|
848
|
-
|
|
1212
|
+
const inHost = summary.location === "outside_project" && typeof action.input.path === "string"
|
|
1213
|
+
&& inHostPath(resolve(action.cwd, action.input.path), options.hostPaths ?? []);
|
|
1214
|
+
if (summary.location === "outside_project" && !inHost) {
|
|
849
1215
|
if (evidenceMode) {
|
|
850
1216
|
const pathNote = action.tool === "write" && summary.exists
|
|
851
1217
|
? `overwrites an existing file outside the project ${summary.path ?? ""}`
|
|
@@ -881,7 +1247,7 @@ export async function evaluateAction(action, options) {
|
|
|
881
1247
|
// count, so the content sent here is the content the guard judges with.
|
|
882
1248
|
const resolved = options.rules?.enabled === false ? null : resolveRulesFile(action.cwd, options.rules);
|
|
883
1249
|
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 });
|
|
1250
|
+
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
1251
|
const result = await ask(judge, request, { timeoutMs: config.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
|
|
886
1252
|
if (!result.ok) {
|
|
887
1253
|
if (!config.failOpen) {
|
|
@@ -918,6 +1284,8 @@ export async function evaluateAction(action, options) {
|
|
|
918
1284
|
judgment.intentMismatch = answers.intent_mismatch.noul;
|
|
919
1285
|
if (summary.command !== undefined && typeof answers.visible?.noul === "number")
|
|
920
1286
|
judgment.visible = answers.visible.noul;
|
|
1287
|
+
if (options.largeOutput?.enabled && action.tool === "bash" && typeof answers.large_output?.noul === "number")
|
|
1288
|
+
judgment.largeOutput = answers.large_output.noul;
|
|
921
1289
|
if (typeof answers.regretted?.noul === "number") {
|
|
922
1290
|
judgment.regretted = answers.regretted.noul;
|
|
923
1291
|
if (typeof answers.regret_target?.choice === "string")
|
|
@@ -1037,6 +1405,12 @@ export async function evaluateAction(action, options) {
|
|
|
1037
1405
|
verdict.offTaskSteer = true;
|
|
1038
1406
|
if (offTaskTraceOnly)
|
|
1039
1407
|
verdict.offTaskTraceOnly = true;
|
|
1408
|
+
// Large output steers and never changes the level: the saver compresses what prints, this asks the agent to print less.
|
|
1409
|
+
if (judgment.largeOutput !== undefined && judgment.largeOutput >= options.largeOutput.threshold && view) {
|
|
1410
|
+
const family = commandFamily(view.command);
|
|
1411
|
+
if (family)
|
|
1412
|
+
verdict.largeOutputFamily = family;
|
|
1413
|
+
}
|
|
1040
1414
|
if (shouldProceedSteer)
|
|
1041
1415
|
verdict.shouldProceedSteer = true;
|
|
1042
1416
|
if (shouldProceedTraceOnlyReasonIndex !== undefined) {
|
|
@@ -1133,6 +1507,24 @@ export function offTaskSteer(verdict) {
|
|
|
1133
1507
|
const score = verdict.judgment?.offTask;
|
|
1134
1508
|
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
1509
|
}
|
|
1510
|
+
/** What the agent reads the first time a command family is judged noisy: the family and one concrete way to print less. */
|
|
1511
|
+
export function largeOutputSteer(verdict) {
|
|
1512
|
+
const family = verdict.largeOutputFamily ?? verdict.summary.tool;
|
|
1513
|
+
const score = verdict.judgment?.largeOutput;
|
|
1514
|
+
const log = `/tmp/warden-${family.replace(/[^A-Za-z0-9]+/g, "-")}.log`;
|
|
1515
|
+
return [
|
|
1516
|
+
`pi-warden: \`${family}\` commands may print far more than you need${score === undefined ? "" : ` (large-output ${percent(score)})`}. This one runs unchanged.`,
|
|
1517
|
+
`Next time, redirect and show the tail: \`${family} … > ${log} 2>&1; tail -40 ${log}\`.`,
|
|
1518
|
+
].join("\n");
|
|
1519
|
+
}
|
|
1520
|
+
/** The large-output steer for this verdict, once per command family per session; `steered` is the session's memory. */
|
|
1521
|
+
export function largeOutputNotice(verdict, steered) {
|
|
1522
|
+
const family = verdict.largeOutputFamily;
|
|
1523
|
+
if (!family || steered.has(family))
|
|
1524
|
+
return undefined;
|
|
1525
|
+
steered.add(family);
|
|
1526
|
+
return largeOutputSteer(verdict);
|
|
1527
|
+
}
|
|
1136
1528
|
/** Offline stand-in for the approval question when TypeSafe is not available. */
|
|
1137
1529
|
export function textApproves(task) {
|
|
1138
1530
|
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 +1558,7 @@ const ACTION_VERBS = {
|
|
|
1166
1558
|
"rm-recursive": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1167
1559
|
"rm-rf": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1168
1560
|
"rm-recursive-dangerous-target": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1561
|
+
"rm-session-scratch": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1169
1562
|
"find-delete": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1170
1563
|
"git-rm": ["delete", "remove", "clean", "tidy", "purge"],
|
|
1171
1564
|
"publish": ["publish"],
|
|
@@ -1265,7 +1658,7 @@ export function isAuthEligible(severity) {
|
|
|
1265
1658
|
// Pattern-detected risky/destructive hits are auth-eligible (user can explicitly authorize)
|
|
1266
1659
|
return true;
|
|
1267
1660
|
}
|
|
1268
|
-
const RM_FAMILY_IDS = new Set(["rm", "rm-recursive", "rm-rf", "rm-recursive-dangerous-target", "find-delete"]);
|
|
1661
|
+
const RM_FAMILY_IDS = new Set(["rm", "rm-recursive", "rm-rf", "rm-recursive-dangerous-target", "rm-session-scratch", "find-delete"]);
|
|
1269
1662
|
const RM_COMMAND_RE = /(?:^|[\s"'(])rm\s+(.*)$/i;
|
|
1270
1663
|
/** Extract file targets from every rm segment in a command. */
|
|
1271
1664
|
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
|
-
|
|
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
|
+
}
|