pi-jev-auto-mode 0.1.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/src/policy.ts ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Deterministic policy layer.
3
+ *
4
+ * Everything in this module runs before JEV. Hard-deny rules are deliberate,
5
+ * non-overridable, and must never be reachable by a probabilistic decision: they
6
+ * are the floor that keeps a mis-calibrated semantic verdict from becoming an
7
+ * approved `rm -rf /`.
8
+ *
9
+ * The command pattern catalogue is adapted from the MIT-licensed
10
+ * `@nilskluewer/pi-auto-permission-gate` extension; see README "Acknowledgements".
11
+ */
12
+
13
+ import { isAbsolute, relative, resolve, sep } from "node:path";
14
+
15
+ export interface CommandRuleConfig {
16
+ /** Shell-style `*` / `?` patterns that auto-approve without asking JEV. */
17
+ readonly allowedCommands: readonly string[];
18
+ /** Shell-style patterns that block immediately, before JEV. */
19
+ readonly disallowedCommands: readonly string[];
20
+ }
21
+
22
+ export interface UserRuleDecision {
23
+ readonly decision: "allow" | "deny";
24
+ readonly pattern: string;
25
+ readonly source: "user-allow" | "user-disallow";
26
+ }
27
+
28
+ export interface CommandPattern {
29
+ readonly name: string;
30
+ readonly pattern: RegExp;
31
+ }
32
+
33
+ /**
34
+ * Read-only inspection commands.
35
+ *
36
+ * These run without producing a decision record: they cannot change state outside
37
+ * the working tree, so gating them would only add latency.
38
+ *
39
+ * The built-in list is deliberately stack-neutral and contains no command that
40
+ * executes project code. A verification runner (`uv run pytest`, `npm run test`,
41
+ * `cargo test`, `go test`, ...) executes arbitrary code from the repository, which
42
+ * is exactly the category the gate exists to judge — so those belong in the user's
43
+ * `safeCommands` setting, where the choice is explicit and local.
44
+ */
45
+ export const SAFE_COMMANDS: readonly string[] = [
46
+ "git status*",
47
+ "git diff*",
48
+ "git log*",
49
+ "git show*",
50
+ "git branch",
51
+ "ls*",
52
+ "pwd",
53
+ "rg*",
54
+ "grep*",
55
+ ];
56
+
57
+ /**
58
+ * Shell control syntax. An allow pattern that matches through `;`, `&&`, `|`,
59
+ * redirection, or substitution would smuggle a second command past the gate, so
60
+ * allow patterns are disabled for commands containing any of these.
61
+ */
62
+ const SHELL_CONTROL_CHARACTERS = /[\r\n;&|<>$`()\\]/;
63
+ const PATH_GLOB_CHARACTERS = /[*?[\]{}]/;
64
+
65
+ const REGEX_SPECIAL_CHARACTERS = /[.*+?^${}()|[\]\\]/g;
66
+
67
+ function commandGlobToRegExp(pattern: string): RegExp {
68
+ let source = "^";
69
+ for (const character of pattern) {
70
+ if (character === "*") {
71
+ source += "[\\s\\S]*";
72
+ } else if (character === "?") {
73
+ source += "[\\s\\S]";
74
+ } else {
75
+ source += character.replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
76
+ }
77
+ }
78
+ return new RegExp(`${source}$`, "i");
79
+ }
80
+
81
+ export function matchesCommandPattern(command: string, pattern: string, allowShellControl: boolean): boolean {
82
+ const normalizedPattern = pattern.trim();
83
+ if (!normalizedPattern) return false;
84
+ if (normalizedPattern.includes("\n") || normalizedPattern.includes("\r")) return false;
85
+ if (!allowShellControl && SHELL_CONTROL_CHARACTERS.test(command)) return false;
86
+ return commandGlobToRegExp(normalizedPattern).test(command.trim());
87
+ }
88
+
89
+ function matchesAnyCommandPattern(
90
+ command: string,
91
+ patterns: readonly string[],
92
+ allowShellControl: boolean,
93
+ ): string | undefined {
94
+ return patterns.find((pattern) => matchesCommandPattern(command, pattern, allowShellControl));
95
+ }
96
+
97
+ /**
98
+ * User rules, in precedence order: deny beats allow. Hard-deny rules are applied
99
+ * separately and cannot be overridden by either list.
100
+ */
101
+ export function evaluateUserCommandRules(
102
+ command: string,
103
+ config: CommandRuleConfig,
104
+ ): UserRuleDecision | undefined {
105
+ // Deny patterns may contain shell control syntax: the user is naming a command
106
+ // to refuse, so a looser match is the safer failure direction.
107
+ const deniedPattern = matchesAnyCommandPattern(command, config.disallowedCommands, true);
108
+ if (deniedPattern) return { decision: "deny", pattern: deniedPattern, source: "user-disallow" };
109
+
110
+ const allowedPattern = matchesAnyCommandPattern(command, config.allowedCommands, false);
111
+ if (allowedPattern) return { decision: "allow", pattern: allowedPattern, source: "user-allow" };
112
+ return undefined;
113
+ }
114
+
115
+ /** Commands that are considered dangerous and must therefore be judged. */
116
+ const DANGEROUS_PATTERNS: readonly CommandPattern[] = [
117
+ // File deletion / destructive filesystem traversal
118
+ {
119
+ name: "recursive/forced rm",
120
+ pattern: /\brm\b(?=[^\n;&|]*\s-(?:[^\s;&|]*[rR][^\s;&|]*[fF]?|[^\s;&|]*[fF][^\s;&|]*[rR])\b|[^\n;&|]*\s--recursive\b)/i,
121
+ },
122
+ { name: "remove Git metadata", pattern: /\brm\b[^\n;&|]*\s(?:\.git|\.git\/|['"]\.git['"])/i },
123
+ { name: "find delete", pattern: /\bfind\b[^\n;&|]*\s-delete\b/i },
124
+ { name: "xargs rm", pattern: /\bxargs\b[^\n;&|]*\brm\b/i },
125
+
126
+ // Package execution and publishing can run third-party code or change remote state
127
+ {
128
+ name: "package execution or publish",
129
+ pattern:
130
+ /\b(?:npm|pnpm|yarn|bun|pip|pip3|uv|poetry|cargo|gem|go|brew|apt(?:-get)?|dnf|pacman)\b[^\n;&|]*\b(?:exec|run|dlx|publish)\b/i,
131
+ },
132
+ { name: "package runner", pattern: /\b(?:npx|pnpm\s+dlx|yarn\s+dlx|bunx|pipx|uvx)\b/i },
133
+
134
+ // Privilege escalation / permission or ownership foot-guns
135
+ { name: "sudo", pattern: /\bsudo\b/i },
136
+ { name: "world-writable permissions", pattern: /\bchmod\b[^\n;&|]*\b777\b/i },
137
+ { name: "recursive chmod/chown", pattern: /\b(?:chmod|chown)\b[^\n;&|]*\s(?:-R|--recursive)\b/i },
138
+
139
+ // Disk / partition / filesystem destruction
140
+ { name: "format filesystem", pattern: /\bmkfs(?:\.[a-z0-9_+-]+)?\b/i },
141
+ { name: "wipe filesystem signatures", pattern: /\bwipefs\b/i },
142
+ { name: "disk shred/wipe", pattern: /\b(?:shred|srm)\b/i },
143
+ { name: "partition editor", pattern: /\b(?:fdisk|parted|gparted|sfdisk|cfdisk)\b/i },
144
+ { name: "macOS disk erase", pattern: /\bdiskutil\b[^\n;&|]*\b(?:erase|partition|apfs\s+delete|apfs\s+erase)\b/i },
145
+ { name: "dd writes to disk device", pattern: /\bdd\b[^\n;&|]*\bof=\/dev\//i },
146
+
147
+ // Git working tree / repo / history destruction
148
+ { name: "git reset hard", pattern: /\bgit\b[^\n;&|]*\breset\b[^\n;&|]*\s--hard\b/i },
149
+ { name: "git clean forced", pattern: /\bgit\b[^\n;&|]*\bclean\b(?=[^\n;&|]*\s-[^\s;&|]*f)[^\n;&|]*/i },
150
+ { name: "git force push", pattern: /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\s--(?:force|force-with-lease|mirror)\b/i },
151
+ { name: "git force push", pattern: /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\s-[^\s;&|]*f[^\s;&|]*\b/i },
152
+ { name: "git branch force-delete", pattern: /\bgit\b[^\n;&|]*\bbranch\b[^\n;&|]*\s-D\b/i },
153
+ { name: "git tag delete", pattern: /\bgit\b[^\n;&|]*\btag\b[^\n;&|]*\s-d\b/i },
154
+ { name: "git remove files", pattern: /\bgit\b[^\n;&|]*\brm\b/i },
155
+ { name: "git checkout all files", pattern: /\bgit\b[^\n;&|]*\bcheckout\b[^\n;&|]*\s--\s+(?:\.|\*)\b/i },
156
+ { name: "git restore all files", pattern: /\bgit\b[^\n;&|]*\brestore\b[^\n;&|]*(?:\s\.\b|\s:\/\b|\s--source\b)/i },
157
+ { name: "git reflog expiry", pattern: /\bgit\b[^\n;&|]*\breflog\b[^\n;&|]*\bexpire\b/i },
158
+ {
159
+ name: "git aggressive prune/gc",
160
+ pattern: /\bgit\b[^\n;&|]*\b(?:gc|prune)\b[^\n;&|]*(?:--prune=(?:now|all)|--expire\s+now|--expire=now)/i,
161
+ },
162
+
163
+ // Containers / volumes can destroy local databases and development state
164
+ {
165
+ name: "docker prune/remove volumes",
166
+ pattern: /\bdocker\b[^\n;&|]*\b(?:system\s+prune|volume\s+(?:rm|prune)|container\s+prune|image\s+prune)\b/i,
167
+ },
168
+ {
169
+ name: "docker compose remove volumes",
170
+ pattern: /\bdocker\s+compose\b[^\n;&|]*\bdown\b[^\n;&|]*(?:\s-v\b|\s--volumes\b)/i,
171
+ },
172
+
173
+ // Running remote scripts gives the author of that script the current user's access
174
+ {
175
+ name: "downloaded script execution",
176
+ pattern: /\b(?:curl|wget)\b[^\n;&|]*(?:\|\s*(?:sh|bash|zsh)\b|\b(?:sh|bash|zsh)\s*<\s*\()/i,
177
+ },
178
+
179
+ // Sending local data out. Missing this class entirely was a real hole: a request
180
+ // that uploads a file matched nothing and ran with no judgment at all.
181
+ {
182
+ name: "network upload of local data",
183
+ pattern:
184
+ /\b(?:curl|wget)\b[^\n;&|]*(?:\s-d\s*@|\s--data(?:-binary|-raw|-urlencode)?\s*@|\s-T\s|\s--upload-file\b|\s-F\s[^\s;&|]*=@|\s--form\s[^\s;&|]*=@)/i,
185
+ },
186
+ { name: "file transfer to a remote host", pattern: /\b(?:scp|rsync|sftp)\b/i },
187
+ { name: "raw network connection", pattern: /\b(?:nc|ncat|netcat|telnet)\b/i },
188
+
189
+ // Reading credential material into the transcript. Reading has no side effect, but
190
+ // a private key pasted into a conversation is a leak with a long half-life.
191
+ {
192
+ name: "reads a credential file",
193
+ pattern:
194
+ /\b(?:cat|bat|less|more|head|tail|xxd|base64|grep|rg)\b[^\n;&|]*(?:\.ssh\/|id_rsa|id_ed25519|id_ecdsa|\.aws\/|\.gnupg|\.npmrc|credentials|\.env\b(?!\.(?:example|sample|template)))/i,
195
+ },
196
+ ];
197
+
198
+ /**
199
+ * Catastrophic targets. These are never handed to JEV: a look-alike approval
200
+ * would be unsafe even when the surrounding conversation seems to ask for it.
201
+ *
202
+ * The list is deliberately small. Everything else belongs to the semantic layer,
203
+ * where context and user intent can legitimately change the answer.
204
+ */
205
+ const HARD_DENY_PATTERNS: readonly CommandPattern[] = [
206
+ {
207
+ name: "recursive delete of a system or home root",
208
+ pattern:
209
+ /\brm\b[^\n;&|]*(?:--recursive|-[^\s;&|]*[rR][^\s;&|]*)[^\n;&|]*\s+["']?(?:\/|~|\$HOME|\$\{HOME\}|\/(?:Users|home|root|System|Applications|Library|etc|usr|var|bin|sbin|opt|private|Volumes))(?:["']?(?:\s|$)|\/)/i,
210
+ },
211
+ {
212
+ name: "unresolved recursive delete target",
213
+ pattern:
214
+ /\brm\b(?=[^\n;&|]*(?:--recursive|-[^\s;&|]*[rR][^\s;&|]*))(?=[^\n;&|]*(?:\$\(|\$\{|\$[A-Za-z_]|\$['"]|~[A-Za-z]|[`*?[\]{}]|\{[^}]*,))[^\n;&|]*/i,
215
+ },
216
+ { name: "filesystem format or signature wipe", pattern: /\b(?:mkfs(?:\.[a-z0-9_+-]+)?|wipefs)\b/i },
217
+ { name: "disk device overwrite", pattern: /\bdd\b[^\n;&|]*\bof\s*=\s*["']?\/dev\//i },
218
+ {
219
+ name: "macOS disk erase or partition",
220
+ pattern: /\bdiskutil\b[^\n;&|]*\b(?:erase|partition|apfs\s+delete|apfs\s+erase)\b/i,
221
+ },
222
+ {
223
+ name: "forced push to a protected branch",
224
+ pattern:
225
+ /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*)\b[^\n;&|]*\b(?:main|master|production|prod)\b/i,
226
+ },
227
+ {
228
+ name: "forced push to a protected branch",
229
+ pattern:
230
+ /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\b(?:main|master|production|prod)\b[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*)\b/i,
231
+ },
232
+ {
233
+ name: "unresolved forced push target",
234
+ pattern:
235
+ /\bgit\b(?=[^\n;&|]*\bpush\b)(?=[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*))(?=[^\n;&|]*(?:\$\(|\$\{|\$[A-Za-z_]|\$['"]|~[A-Za-z]|[`*?[\]{}]|\{[^}]*,))[^\n;&|]*/i,
236
+ },
237
+ ];
238
+
239
+ export function hardDenyReasons(command: string): string[] {
240
+ return unique(HARD_DENY_PATTERNS.filter(({ pattern }) => pattern.test(command)).map(({ name }) => name));
241
+ }
242
+
243
+ const LOCAL_DELETION_REASONS = new Set(["recursive/forced rm", "find delete"]);
244
+
245
+ const FIND_NARROWING_PREDICATES = new Set([
246
+ "-atime",
247
+ "-ctime",
248
+ "-empty",
249
+ "-group",
250
+ "-iname",
251
+ "-ipath",
252
+ "-iregex",
253
+ "-links",
254
+ "-maxdepth",
255
+ "-mindepth",
256
+ "-mtime",
257
+ "-name",
258
+ "-newer",
259
+ "-newermt",
260
+ "-path",
261
+ "-perm",
262
+ "-regex",
263
+ "-size",
264
+ "-type",
265
+ "-user",
266
+ ]);
267
+
268
+ function isSafeRelativeDeletionTarget(target: string, cwd: string, allowCurrentDirectory: boolean): boolean {
269
+ if (!target) return false;
270
+ if (target.startsWith("/") || target.startsWith("~") || target.startsWith("$")) return false;
271
+ if (/^[A-Za-z]:[\\/]/.test(target)) return false;
272
+ if (PATH_GLOB_CHARACTERS.test(target)) return false;
273
+
274
+ const normalizedSegments = target.replace(/^\.\/+/, "").split(/[\\/]/);
275
+ if (!allowCurrentDirectory && normalizedSegments.length === 1 && normalizedSegments[0] === "") return false;
276
+ if (normalizedSegments.some((segment) => segment === ".." || segment === ".git")) return false;
277
+
278
+ const projectRoot = resolve(cwd);
279
+ const resolvedTarget = resolve(projectRoot, target);
280
+ const relativeTarget = relative(projectRoot, resolvedTarget);
281
+ return (
282
+ Boolean(relativeTarget) &&
283
+ relativeTarget !== ".." &&
284
+ !relativeTarget.startsWith(`..${sep}`) &&
285
+ !relativeTarget.startsWith(sep)
286
+ );
287
+ }
288
+
289
+ function isScopedRmCommand(command: string, cwd: string): boolean {
290
+ if (SHELL_CONTROL_CHARACTERS.test(command) || PATH_GLOB_CHARACTERS.test(command)) return false;
291
+ const tokens = command.trim().split(/[ \t]+/).filter(Boolean);
292
+ if (tokens.shift()?.toLowerCase() !== "rm") return false;
293
+
294
+ let optionsEnded = false;
295
+ const targets: string[] = [];
296
+ for (const token of tokens) {
297
+ if (!optionsEnded && token === "--") {
298
+ optionsEnded = true;
299
+ continue;
300
+ }
301
+ if (!optionsEnded && token.startsWith("-")) continue;
302
+ // An option after the first target means the command is not a simple deletion.
303
+ if (token.startsWith("-")) return false;
304
+ targets.push(token);
305
+ }
306
+
307
+ return targets.length > 0 && targets.every((target) => isSafeRelativeDeletionTarget(target, cwd, false));
308
+ }
309
+
310
+ function isScopedFindDeleteCommand(command: string, cwd: string): boolean {
311
+ if (SHELL_CONTROL_CHARACTERS.test(command) || PATH_GLOB_CHARACTERS.test(command)) return false;
312
+ const tokens = command.trim().split(/[ \t]+/).filter(Boolean);
313
+ if (tokens.shift()?.toLowerCase() !== "find") return false;
314
+ if (tokens.includes("-exec") || tokens.includes("-execdir")) return false;
315
+ if (!tokens.includes("-delete")) return false;
316
+
317
+ const expressionStart = tokens.findIndex(
318
+ (token) => token.startsWith("-") || token === "!" || token === "(" || token === ")",
319
+ );
320
+ if (expressionStart <= 0) return false;
321
+
322
+ const roots = tokens.slice(0, expressionStart);
323
+ const hasNarrowingPredicate = tokens.some((token) => FIND_NARROWING_PREDICATES.has(token));
324
+ return roots.length > 0 && roots.every((root) => isSafeRelativeDeletionTarget(root, cwd, hasNarrowingPredicate));
325
+ }
326
+
327
+ /** `rm -rf build` / `find build -type f -delete` under the working directory. */
328
+ export function isScopedLocalDeletionCommand(command: string, cwd: string): boolean {
329
+ return isScopedRmCommand(command, cwd) || isScopedFindDeleteCommand(command, cwd);
330
+ }
331
+
332
+ export function isSafeCommand(command: string, extraPatterns: readonly string[] = []): boolean {
333
+ return (
334
+ matchesAnyCommandPattern(command, SAFE_COMMANDS, false) !== undefined ||
335
+ matchesAnyCommandPattern(command, extraPatterns, false) !== undefined
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Names of the dangerous patterns a bash command matches, i.e. the reasons this
341
+ * call has to be judged instead of running straight through.
342
+ *
343
+ * Returns an empty array when the command is safe to run as far as this layer can
344
+ * tell. That is the fast path: the default is to stay out of the way.
345
+ */
346
+ export function dangerousReasons(command: string, cwd?: string): string[] {
347
+ const reasons = unique(DANGEROUS_PATTERNS.filter(({ pattern }) => pattern.test(command)).map(({ name }) => name));
348
+ if (cwd && isScopedLocalDeletionCommand(command, cwd)) {
349
+ return reasons.filter((reason) => !LOCAL_DELETION_REASONS.has(reason));
350
+ }
351
+ return reasons;
352
+ }
353
+
354
+ /** Directories that hold credentials, agent configuration, or CI definitions. */
355
+ export const PROTECTED_DIRECTORY_SEGMENTS: readonly string[] = [
356
+ ".git",
357
+ ".ssh",
358
+ ".aws",
359
+ ".gnupg",
360
+ ".husky",
361
+ ".pi",
362
+ ".claude",
363
+ ".codex",
364
+ ];
365
+
366
+ /** Path fragments that are security-relevant even outside the segments above. */
367
+ const PROTECTED_PATH_FRAGMENTS: readonly string[] = ["/.github/workflows/", "/.config/gh/"];
368
+
369
+ const PROTECTED_FILE_PATTERNS: readonly RegExp[] = [
370
+ /^\.env(?:\..+)?$/i,
371
+ /^\.npmrc$/i,
372
+ /^\.netrc$/i,
373
+ /^\.mcp\.json$/i,
374
+ /^credentials(?:\.json)?$/i,
375
+ /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$/i,
376
+ /\.(?:pem|key|p12|pfx)$/i,
377
+ /^\.(?:zshrc|bashrc|bash_profile|profile|zprofile|zlogin)$/i,
378
+ // Agent instruction files are a prompt-injection surface: a write there can
379
+ // change what the agent believes it has been told.
380
+ /^AGENTS\.md$/i,
381
+ /^CLAUDE\.md$/i,
382
+ ];
383
+
384
+ function normalizeForMatching(absolutePath: string): string {
385
+ return absolutePath.replace(/\\/g, "/");
386
+ }
387
+
388
+ /**
389
+ * Classify a path against the protected locations.
390
+ *
391
+ * `extraProtectedPaths` comes from settings. An entry containing `/` is matched as a
392
+ * path fragment; anything else is matched against the file name. Matching is
393
+ * case-insensitive on the normalized path.
394
+ */
395
+ export function protectedPathReason(
396
+ absolutePath: string,
397
+ extraProtectedPaths: readonly string[] = [],
398
+ ): string | undefined {
399
+ // Matching is case-insensitive, but the reported name keeps the original case:
400
+ // the message is read by a person looking at their own path.
401
+ const normalized = normalizeForMatching(absolutePath);
402
+ const lowered = normalized.toLowerCase();
403
+ const segments = normalized.split("/").filter(Boolean);
404
+ const loweredSegments = lowered.split("/").filter(Boolean);
405
+ const baseName = segments[segments.length - 1] ?? "";
406
+ const loweredBaseName = loweredSegments[loweredSegments.length - 1] ?? "";
407
+
408
+ const extraEntry = extraProtectedPaths
409
+ .map((entry) => entry.trim())
410
+ .filter((entry) => entry.length > 0)
411
+ .find((entry) => {
412
+ const loweredEntry = entry.toLowerCase();
413
+ return loweredEntry.includes("/") ? lowered.includes(loweredEntry) : loweredBaseName === loweredEntry;
414
+ });
415
+ if (extraEntry) return `configured protected path \`${extraEntry}\``;
416
+
417
+ const segment = loweredSegments.find((part) => PROTECTED_DIRECTORY_SEGMENTS.includes(part));
418
+ if (segment) return `protected directory \`${segment}\``;
419
+
420
+ const fragment = PROTECTED_PATH_FRAGMENTS.find((part) => lowered.includes(part));
421
+ if (fragment) return `protected path \`${fragment}\``;
422
+
423
+ const filePattern = PROTECTED_FILE_PATTERNS.find((pattern) => pattern.test(baseName));
424
+ if (filePattern) return `protected file \`${baseName}\``;
425
+
426
+ return undefined;
427
+ }
428
+
429
+ export interface WriteTarget {
430
+ readonly absolute: string;
431
+ readonly relativeToCwd: string | undefined;
432
+ readonly outsideCwd: boolean;
433
+ readonly protectedReason: string | undefined;
434
+ }
435
+
436
+ /**
437
+ * Classify a write/edit target lexically.
438
+ *
439
+ * A symlink inside the working directory can still point outside it; resolving
440
+ * that needs a filesystem call and belongs to the JEV layer's state building.
441
+ */
442
+ export function classifyWriteTarget(
443
+ inputPath: string,
444
+ cwd: string,
445
+ extraProtectedPaths: readonly string[] = [],
446
+ ): WriteTarget {
447
+ const projectRoot = resolve(cwd);
448
+ const absolute = resolve(projectRoot, inputPath);
449
+ const relativeToCwd = relative(projectRoot, absolute);
450
+ const outsideCwd = isAbsolute(relativeToCwd)
451
+ ? true
452
+ : relativeToCwd === ".." || relativeToCwd.startsWith(`..${sep}`) || relativeToCwd === "";
453
+
454
+ return {
455
+ absolute,
456
+ relativeToCwd: outsideCwd ? undefined : relativeToCwd,
457
+ outsideCwd,
458
+ protectedReason: protectedPathReason(absolute, extraProtectedPaths),
459
+ };
460
+ }
461
+
462
+ export function unique<T>(values: readonly T[]): T[] {
463
+ return [...new Set(values)];
464
+ }
package/src/records.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Decision records.
3
+ *
4
+ * Records are written with `pi.appendEntry`, which keeps them out of the LLM
5
+ * context on purpose: the model must not learn to argue with the gate, and a
6
+ * recorded rationale should not become ammunition for the next tool call.
7
+ */
8
+
9
+ import { Box, Text } from "@earendil-works/pi-tui";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import type { ConditionReport, DecisionSource } from "./decide.ts";
12
+ import { formatThreshold } from "./jev/decide.ts";
13
+
14
+ export const DECISION_ENTRY_TYPE = "jev-auto-mode-decision";
15
+
16
+ export interface DecisionRecord {
17
+ readonly tool: string;
18
+ readonly summary: string;
19
+ readonly reasons: readonly string[];
20
+ readonly status: "allowed" | "blocked" | "confirmed" | "cancelled";
21
+ readonly source: DecisionSource;
22
+ readonly rationale: string;
23
+ /** One entry per condition that was asked, in the order they were asked. */
24
+ readonly conditions?: readonly ConditionReport[];
25
+ readonly decidingRule?: string;
26
+ readonly clearedByIntent?: readonly string[];
27
+ readonly probabilities?: Readonly<Record<string, number>>;
28
+ readonly model?: string;
29
+ readonly latencyMs?: number;
30
+ readonly timestamp: number;
31
+ }
32
+
33
+ export type DecisionRecorder = (record: DecisionRecord) => void;
34
+
35
+ export function createRecorder(pi: Pick<ExtensionAPI, "appendEntry">): DecisionRecorder {
36
+ return (record) => {
37
+ try {
38
+ pi.appendEntry(DECISION_ENTRY_TYPE, record);
39
+ } catch (error) {
40
+ console.warn("[jev-auto-mode] could not record a decision:", error);
41
+ }
42
+ };
43
+ }
44
+
45
+ const STATUS_LABEL: Record<DecisionRecord["status"], string> = {
46
+ allowed: "allowed",
47
+ blocked: "blocked",
48
+ confirmed: "confirmed by user",
49
+ cancelled: "cancelled by user",
50
+ };
51
+
52
+ export function formatDecisionLine(record: DecisionRecord): string {
53
+ const parts = [`${record.tool}: ${STATUS_LABEL[record.status]}`, `via ${record.source}`];
54
+ if (record.decidingRule) parts.push(`decided by ${record.decidingRule}`);
55
+ if (record.model) parts.push(record.model);
56
+ if (typeof record.latencyMs === "number") parts.push(`${Math.round(record.latencyMs)}ms`);
57
+ return parts.join(" · ");
58
+ }
59
+
60
+ const VERDICT_MARK: Record<ConditionReport["verdict"], string> = {
61
+ satisfied: "pass",
62
+ rejected: "reject",
63
+ uncertain: "unclear",
64
+ ignored: "ignored",
65
+ };
66
+
67
+ /**
68
+ * One line per condition, with the band it landed in and the threshold it was
69
+ * compared against. This is the view used to decide whether a threshold needs to
70
+ * move, so it shows the passing values too.
71
+ */
72
+ export function formatConditionLines(record: DecisionRecord): string[] {
73
+ return (record.conditions ?? []).map((condition) => {
74
+ const bounds =
75
+ condition.verdict === "satisfied"
76
+ ? `>= ${formatThreshold(condition.threshold)}`
77
+ : condition.verdict === "rejected"
78
+ ? `<= ${formatThreshold(1 - condition.threshold)}`
79
+ : `${formatThreshold(1 - condition.threshold)}-${formatThreshold(condition.threshold)}`;
80
+ const marks = [
81
+ record.decidingRule === condition.ruleId ? "<- decided" : "",
82
+ condition.clearedByIntent ? "(cleared by the user's request)" : "",
83
+ ].filter(Boolean);
84
+ return `${condition.ruleId} p=${condition.probability.toFixed(2)} ${VERDICT_MARK[condition.verdict]} (t=${formatThreshold(condition.threshold)}, ${bounds}) ${marks.join(" ")}`.trimEnd();
85
+ });
86
+ }
87
+
88
+ export function registerDecisionEntryRenderer(pi: Pick<ExtensionAPI, "registerEntryRenderer">): void {
89
+ pi.registerEntryRenderer<DecisionRecord>(DECISION_ENTRY_TYPE, (entry, options, theme) => {
90
+ const record = entry.data;
91
+ if (!record) return undefined;
92
+
93
+ const approved = record.status === "allowed" || record.status === "confirmed";
94
+ const icon = approved ? "🛡" : "⛔";
95
+ const heading = `${icon} ${theme.bold("jev auto mode")} ${theme.fg(
96
+ approved ? "success" : "error",
97
+ STATUS_LABEL[record.status],
98
+ )}`;
99
+
100
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
101
+ box.addChild(new Text(heading));
102
+ box.addChild(new Text(theme.fg("muted", formatDecisionLine(record))));
103
+ box.addChild(new Text(record.summary));
104
+ if (record.reasons.length > 0) {
105
+ box.addChild(new Text(theme.fg("dim", `reasons: ${record.reasons.join(", ")}`)));
106
+ }
107
+ box.addChild(new Text(theme.fg("dim", `rationale: ${record.rationale}`)));
108
+
109
+ if (options.expanded) {
110
+ for (const line of formatConditionLines(record)) {
111
+ box.addChild(new Text(theme.fg("dim", line)));
112
+ }
113
+ box.addChild(new Text(theme.fg("dim", JSON.stringify(record, null, 2))));
114
+ }
115
+
116
+ return box;
117
+ });
118
+ }