@herbertgao/pi-extensions 2026.9.8 โ†’ 2026.9.9

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.
Files changed (94) hide show
  1. package/README.md +8 -4
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@narumitw/pi-btw/README.md +6 -1
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts +209 -23
  10. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +1 -12
  13. package/node_modules/@narumitw/pi-btw/src/menu.ts +244 -15
  14. package/node_modules/@narumitw/pi-btw/src/settings.ts +5 -0
  15. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +110 -0
  16. package/node_modules/pi-jev-auto-mode/LICENSE +21 -0
  17. package/node_modules/pi-jev-auto-mode/README.md +285 -0
  18. package/node_modules/pi-jev-auto-mode/SECURITY.md +32 -0
  19. package/node_modules/pi-jev-auto-mode/docs/calibration.md +154 -0
  20. package/node_modules/pi-jev-auto-mode/docs/design.md +164 -0
  21. package/node_modules/pi-jev-auto-mode/docs/security.md +124 -0
  22. package/node_modules/pi-jev-auto-mode/index.ts +1 -0
  23. package/node_modules/pi-jev-auto-mode/package.json +72 -0
  24. package/node_modules/pi-jev-auto-mode/src/call.ts +180 -0
  25. package/node_modules/pi-jev-auto-mode/src/decide.ts +86 -0
  26. package/node_modules/pi-jev-auto-mode/src/extension.ts +862 -0
  27. package/node_modules/pi-jev-auto-mode/src/intent.ts +71 -0
  28. package/node_modules/pi-jev-auto-mode/src/jev/availability.ts +53 -0
  29. package/node_modules/pi-jev-auto-mode/src/jev/criteria.ts +19 -0
  30. package/node_modules/pi-jev-auto-mode/src/jev/decide.ts +187 -0
  31. package/node_modules/pi-jev-auto-mode/src/jev/engine.ts +165 -0
  32. package/node_modules/pi-jev-auto-mode/src/jev/index.ts +20 -0
  33. package/node_modules/pi-jev-auto-mode/src/jev/questions.ts +273 -0
  34. package/node_modules/pi-jev-auto-mode/src/jev/response.ts +64 -0
  35. package/node_modules/pi-jev-auto-mode/src/jev/state.ts +20 -0
  36. package/node_modules/pi-jev-auto-mode/src/jev/transport.ts +117 -0
  37. package/node_modules/pi-jev-auto-mode/src/jev/types.ts +46 -0
  38. package/node_modules/pi-jev-auto-mode/src/policy.ts +558 -0
  39. package/node_modules/pi-jev-auto-mode/src/records.ts +118 -0
  40. package/node_modules/pi-jev-auto-mode/src/settings.ts +327 -0
  41. package/node_modules/pi-jev-auto-mode/src/ui.ts +233 -0
  42. package/node_modules/pi-lens/CHANGELOG.md +45 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +21 -9
  44. package/node_modules/pi-lens/dist/clients/cascade-format.js +24 -0
  45. package/node_modules/pi-lens/dist/clients/extension-log.js +48 -1
  46. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +11 -12
  47. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +3 -0
  48. package/node_modules/pi-lens/dist/clients/lsp/index.js +41 -7
  49. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +9 -2
  50. package/node_modules/pi-lens/dist/clients/path-utils.js +51 -9
  51. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +271 -0
  52. package/node_modules/pi-lens/dist/clients/pipeline.js +15 -1
  53. package/node_modules/pi-lens/dist/clients/runtime-turn.js +124 -7
  54. package/node_modules/pi-lens/dist/clients/skills-resolver.js +38 -2
  55. package/node_modules/pi-lens/dist/clients/widget-state.js +152 -6
  56. package/node_modules/pi-lens/dist/index.js +729 -344
  57. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +14 -2
  58. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +84 -17
  59. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +32 -5
  60. package/node_modules/pi-lens/package.json +1 -1
  61. package/node_modules/pi-typesafe/LICENSE +21 -0
  62. package/node_modules/pi-typesafe/README.md +153 -0
  63. package/node_modules/pi-typesafe/dist/ask.d.ts +32 -0
  64. package/node_modules/pi-typesafe/dist/ask.js +25 -0
  65. package/node_modules/pi-typesafe/dist/auth.d.ts +56 -0
  66. package/node_modules/pi-typesafe/dist/auth.js +114 -0
  67. package/node_modules/pi-typesafe/dist/batch.d.ts +74 -0
  68. package/node_modules/pi-typesafe/dist/batch.js +116 -0
  69. package/node_modules/pi-typesafe/dist/calibrate.d.ts +106 -0
  70. package/node_modules/pi-typesafe/dist/calibrate.js +157 -0
  71. package/node_modules/pi-typesafe/dist/client.d.ts +69 -0
  72. package/node_modules/pi-typesafe/dist/client.js +188 -0
  73. package/node_modules/pi-typesafe/dist/credentials.d.ts +46 -0
  74. package/node_modules/pi-typesafe/dist/credentials.js +112 -0
  75. package/node_modules/pi-typesafe/dist/errors.d.ts +9 -0
  76. package/node_modules/pi-typesafe/dist/errors.js +31 -0
  77. package/node_modules/pi-typesafe/dist/extension.d.ts +3 -0
  78. package/node_modules/pi-typesafe/dist/extension.js +222 -0
  79. package/node_modules/pi-typesafe/dist/index.d.ts +18 -0
  80. package/node_modules/pi-typesafe/dist/index.js +9 -0
  81. package/node_modules/pi-typesafe/dist/key-prompt.d.ts +3 -0
  82. package/node_modules/pi-typesafe/dist/key-prompt.js +43 -0
  83. package/node_modules/pi-typesafe/dist/login.d.ts +27 -0
  84. package/node_modules/pi-typesafe/dist/login.js +38 -0
  85. package/node_modules/pi-typesafe/dist/schema.d.ts +43 -0
  86. package/node_modules/pi-typesafe/dist/schema.js +152 -0
  87. package/node_modules/pi-typesafe/dist/ui.d.ts +3 -0
  88. package/node_modules/pi-typesafe/dist/ui.js +3 -0
  89. package/node_modules/pi-typesafe/dist/usage.d.ts +73 -0
  90. package/node_modules/pi-typesafe/dist/usage.js +182 -0
  91. package/node_modules/pi-typesafe/examples/decision-extension.ts +41 -0
  92. package/node_modules/pi-typesafe/extensions/index.js +2 -0
  93. package/node_modules/pi-typesafe/package.json +89 -0
  94. package/package.json +15 -8
@@ -0,0 +1,558 @@
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
+ // Shell state and navigation
47
+ "pwd",
48
+ "cd*",
49
+ "ls*",
50
+ "tree*",
51
+ "whoami",
52
+ "hostname",
53
+ "uname*",
54
+ "date",
55
+ // Reading files and stdin. Shell redirection and pipes are rejected by the matcher,
56
+ // and a credential path is caught by the dangerous patterns before this list, so
57
+ // `cat*` cannot read a secret out of the gate's sight.
58
+ "cat*",
59
+ "bat*",
60
+ "head*",
61
+ "tail*",
62
+ "less*",
63
+ "wc*",
64
+ "file*",
65
+ "stat*",
66
+ "realpath*",
67
+ "readlink*",
68
+ "basename*",
69
+ "dirname*",
70
+ "du*",
71
+ "df*",
72
+ // `find -delete` and `find -exec` match dangerous patterns and are judged first.
73
+ "find*",
74
+ // Searching and transforming text without writing files
75
+ "grep*",
76
+ "rg*",
77
+ "ag*",
78
+ "jq*",
79
+ "diff*",
80
+ "cmp*",
81
+ "sort*",
82
+ "uniq*",
83
+ "cut*",
84
+ "column*",
85
+ "nl*",
86
+ "xxd*",
87
+ // Interpreter and tool versions
88
+ "node --version*",
89
+ "npm --version*",
90
+ "python --version*",
91
+ "python3 --version*",
92
+ "uv --version*",
93
+ "go version*",
94
+ "cargo --version*",
95
+ "gh --version*",
96
+ // Git inspection. Destructive variants (`tag -d`, `branch -D`, `clean -f`,
97
+ // `push --force`) match dangerous patterns and are judged before this list.
98
+ "git status*",
99
+ "git diff*",
100
+ "git log*",
101
+ "git show*",
102
+ "git branch",
103
+ "git remote",
104
+ "git remote -v",
105
+ "git blame*",
106
+ "git shortlog*",
107
+ "git describe*",
108
+ "git rev-parse*",
109
+ "git ls-files*",
110
+ "git ls-tree*",
111
+ "git worktree list*",
112
+ "git stash list*",
113
+ "git tag",
114
+ ];
115
+
116
+ /**
117
+ * Shell control syntax. An allow pattern that matches through `;`, `&&`, `|`,
118
+ * redirection, or substitution would smuggle a second command past the gate, so
119
+ * allow patterns are disabled for commands containing any of these.
120
+ */
121
+ const SHELL_CONTROL_CHARACTERS = /[\r\n;&|<>$`()\\]/;
122
+ const PATH_GLOB_CHARACTERS = /[*?[\]{}]/;
123
+
124
+ const REGEX_SPECIAL_CHARACTERS = /[.*+?^${}()|[\]\\]/g;
125
+
126
+ function commandGlobToRegExp(pattern: string): RegExp {
127
+ let source = "^";
128
+ for (const character of pattern) {
129
+ if (character === "*") {
130
+ source += "[\\s\\S]*";
131
+ } else if (character === "?") {
132
+ source += "[\\s\\S]";
133
+ } else {
134
+ source += character.replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
135
+ }
136
+ }
137
+ return new RegExp(`${source}$`, "i");
138
+ }
139
+
140
+ export function matchesCommandPattern(command: string, pattern: string, allowShellControl: boolean): boolean {
141
+ const normalizedPattern = pattern.trim();
142
+ if (!normalizedPattern) return false;
143
+ if (normalizedPattern.includes("\n") || normalizedPattern.includes("\r")) return false;
144
+ if (!allowShellControl && SHELL_CONTROL_CHARACTERS.test(command)) return false;
145
+ return commandGlobToRegExp(normalizedPattern).test(command.trim());
146
+ }
147
+
148
+ function matchesAnyCommandPattern(
149
+ command: string,
150
+ patterns: readonly string[],
151
+ allowShellControl: boolean,
152
+ ): string | undefined {
153
+ return patterns.find((pattern) => matchesCommandPattern(command, pattern, allowShellControl));
154
+ }
155
+
156
+ /**
157
+ * User rules, in precedence order: deny beats allow. Hard-deny rules are applied
158
+ * separately and cannot be overridden by either list.
159
+ */
160
+ export function evaluateUserCommandRules(
161
+ command: string,
162
+ config: CommandRuleConfig,
163
+ ): UserRuleDecision | undefined {
164
+ // Deny patterns may contain shell control syntax: the user is naming a command
165
+ // to refuse, so a looser match is the safer failure direction.
166
+ const deniedPattern = matchesAnyCommandPattern(command, config.disallowedCommands, true);
167
+ if (deniedPattern) return { decision: "deny", pattern: deniedPattern, source: "user-disallow" };
168
+
169
+ const allowedPattern = matchesAnyCommandPattern(command, config.allowedCommands, false);
170
+ if (allowedPattern) return { decision: "allow", pattern: allowedPattern, source: "user-allow" };
171
+ return undefined;
172
+ }
173
+
174
+ /** Commands that are considered dangerous and must therefore be judged. */
175
+ const DANGEROUS_PATTERNS: readonly CommandPattern[] = [
176
+ // File deletion / destructive filesystem traversal
177
+ {
178
+ name: "recursive/forced rm",
179
+ pattern: /\brm\b(?=[^\n;&|]*\s-(?:[^\s;&|]*[rR][^\s;&|]*[fF]?|[^\s;&|]*[fF][^\s;&|]*[rR])\b|[^\n;&|]*\s--recursive\b)/i,
180
+ },
181
+ { name: "remove Git metadata", pattern: /\brm\b[^\n;&|]*\s(?:\.git|\.git\/|['"]\.git['"])/i },
182
+ { name: "find delete", pattern: /\bfind\b[^\n;&|]*\s-delete\b/i },
183
+ { name: "xargs rm", pattern: /\bxargs\b[^\n;&|]*\brm\b/i },
184
+
185
+ // Package execution and publishing can run third-party code or change remote state
186
+ {
187
+ name: "package execution or publish",
188
+ pattern:
189
+ /\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,
190
+ },
191
+ { name: "package runner", pattern: /\b(?:npx|pnpm\s+dlx|yarn\s+dlx|bunx|pipx|uvx)\b/i },
192
+
193
+ // Privilege escalation / permission or ownership foot-guns
194
+ { name: "sudo", pattern: /\bsudo\b/i },
195
+ { name: "world-writable permissions", pattern: /\bchmod\b[^\n;&|]*\b777\b/i },
196
+ { name: "recursive chmod/chown", pattern: /\b(?:chmod|chown)\b[^\n;&|]*\s(?:-R|--recursive)\b/i },
197
+
198
+ // Disk / partition / filesystem destruction
199
+ { name: "format filesystem", pattern: /\bmkfs(?:\.[a-z0-9_+-]+)?\b/i },
200
+ { name: "wipe filesystem signatures", pattern: /\bwipefs\b/i },
201
+ { name: "disk shred/wipe", pattern: /\b(?:shred|srm)\b/i },
202
+ { name: "partition editor", pattern: /\b(?:fdisk|parted|gparted|sfdisk|cfdisk)\b/i },
203
+ { name: "macOS disk erase", pattern: /\bdiskutil\b[^\n;&|]*\b(?:erase|partition|apfs\s+delete|apfs\s+erase)\b/i },
204
+ { name: "dd writes to disk device", pattern: /\bdd\b[^\n;&|]*\bof=\/dev\//i },
205
+
206
+ // Git working tree / repo / history destruction
207
+ { name: "git reset hard", pattern: /\bgit\b[^\n;&|]*\breset\b[^\n;&|]*\s--hard\b/i },
208
+ { name: "git clean forced", pattern: /\bgit\b[^\n;&|]*\bclean\b(?=[^\n;&|]*\s-[^\s;&|]*f)[^\n;&|]*/i },
209
+ { name: "git force push", pattern: /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\s--(?:force|force-with-lease|mirror)\b/i },
210
+ { name: "git force push", pattern: /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\s-[^\s;&|]*f[^\s;&|]*\b/i },
211
+ { name: "git branch force-delete", pattern: /\bgit\b[^\n;&|]*\bbranch\b[^\n;&|]*\s-D\b/i },
212
+ { name: "git tag delete", pattern: /\bgit\b[^\n;&|]*\btag\b[^\n;&|]*\s-d\b/i },
213
+ { name: "git remove files", pattern: /\bgit\b[^\n;&|]*\brm\b/i },
214
+ { name: "git checkout all files", pattern: /\bgit\b[^\n;&|]*\bcheckout\b[^\n;&|]*\s--\s+(?:\.|\*)\b/i },
215
+ { name: "git restore all files", pattern: /\bgit\b[^\n;&|]*\brestore\b[^\n;&|]*(?:\s\.\b|\s:\/\b|\s--source\b)/i },
216
+ { name: "git reflog expiry", pattern: /\bgit\b[^\n;&|]*\breflog\b[^\n;&|]*\bexpire\b/i },
217
+ {
218
+ name: "git aggressive prune/gc",
219
+ pattern: /\bgit\b[^\n;&|]*\b(?:gc|prune)\b[^\n;&|]*(?:--prune=(?:now|all)|--expire\s+now|--expire=now)/i,
220
+ },
221
+
222
+ // Containers / volumes can destroy local databases and development state
223
+ {
224
+ name: "docker prune/remove volumes",
225
+ pattern: /\bdocker\b[^\n;&|]*\b(?:system\s+prune|volume\s+(?:rm|prune)|container\s+prune|image\s+prune)\b/i,
226
+ },
227
+ {
228
+ name: "docker compose remove volumes",
229
+ pattern: /\bdocker\s+compose\b[^\n;&|]*\bdown\b[^\n;&|]*(?:\s-v\b|\s--volumes\b)/i,
230
+ },
231
+
232
+ // Running remote scripts gives the author of that script the current user's access
233
+ {
234
+ name: "downloaded script execution",
235
+ pattern: /\b(?:curl|wget)\b[^\n;&|]*(?:\|\s*(?:sh|bash|zsh)\b|\b(?:sh|bash|zsh)\s*<\s*\()/i,
236
+ },
237
+
238
+ // Sending local data out. Missing this class entirely was a real hole: a request
239
+ // that uploads a file matched nothing and ran with no judgment at all.
240
+ {
241
+ name: "network upload of local data",
242
+ pattern:
243
+ /\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,
244
+ },
245
+ { name: "file transfer to a remote host", pattern: /\b(?:scp|rsync|sftp)\b/i },
246
+ { name: "raw network connection", pattern: /\b(?:nc|ncat|netcat|telnet)\b/i },
247
+
248
+ // Reading credential material into the transcript. Reading has no side effect, but
249
+ // a private key pasted into a conversation is a leak with a long half-life.
250
+ {
251
+ name: "reads a credential file",
252
+ pattern:
253
+ /\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,
254
+ },
255
+ ];
256
+
257
+ /**
258
+ * Catastrophic targets. These are never handed to Jev: a look-alike approval
259
+ * would be unsafe even when the surrounding conversation seems to ask for it.
260
+ *
261
+ * The list is deliberately small. Everything else belongs to the semantic layer,
262
+ * where context and user intent can legitimately change the answer.
263
+ */
264
+ const HARD_DENY_PATTERNS: readonly CommandPattern[] = [
265
+ {
266
+ name: "recursive delete of a system or home root",
267
+ pattern:
268
+ /\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,
269
+ },
270
+ {
271
+ name: "unresolved recursive delete target",
272
+ pattern:
273
+ /\brm\b(?=[^\n;&|]*(?:--recursive|-[^\s;&|]*[rR][^\s;&|]*))(?=[^\n;&|]*(?:\$\(|\$\{|\$[A-Za-z_]|\$['"]|~[A-Za-z]|[`*?[\]{}]|\{[^}]*,))[^\n;&|]*/i,
274
+ },
275
+ { name: "filesystem format or signature wipe", pattern: /\b(?:mkfs(?:\.[a-z0-9_+-]+)?|wipefs)\b/i },
276
+ { name: "disk device overwrite", pattern: /\bdd\b[^\n;&|]*\bof\s*=\s*["']?\/dev\//i },
277
+ {
278
+ name: "macOS disk erase or partition",
279
+ pattern: /\bdiskutil\b[^\n;&|]*\b(?:erase|partition|apfs\s+delete|apfs\s+erase)\b/i,
280
+ },
281
+ {
282
+ name: "forced push to a protected branch",
283
+ pattern:
284
+ /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*)\b[^\n;&|]*\b(?:main|master|production|prod)\b/i,
285
+ },
286
+ {
287
+ name: "forced push to a protected branch",
288
+ pattern:
289
+ /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*\b(?:main|master|production|prod)\b[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*)\b/i,
290
+ },
291
+ {
292
+ name: "unresolved forced push target",
293
+ pattern:
294
+ /\bgit\b(?=[^\n;&|]*\bpush\b)(?=[^\n;&|]*(?:--force(?:-with-lease)?|-[^\s;&|]*f[^\s;&|]*))(?=[^\n;&|]*(?:\$\(|\$\{|\$[A-Za-z_]|\$['"]|~[A-Za-z]|[`*?[\]{}]|\{[^}]*,))[^\n;&|]*/i,
295
+ },
296
+ ];
297
+
298
+ export function hardDenyReasons(command: string): string[] {
299
+ return unique(HARD_DENY_PATTERNS.filter(({ pattern }) => pattern.test(command)).map(({ name }) => name));
300
+ }
301
+
302
+ const LOCAL_DELETION_REASONS = new Set(["recursive/forced rm", "find delete"]);
303
+
304
+ const FIND_NARROWING_PREDICATES = new Set([
305
+ "-atime",
306
+ "-ctime",
307
+ "-empty",
308
+ "-group",
309
+ "-iname",
310
+ "-ipath",
311
+ "-iregex",
312
+ "-links",
313
+ "-maxdepth",
314
+ "-mindepth",
315
+ "-mtime",
316
+ "-name",
317
+ "-newer",
318
+ "-newermt",
319
+ "-path",
320
+ "-perm",
321
+ "-regex",
322
+ "-size",
323
+ "-type",
324
+ "-user",
325
+ ]);
326
+
327
+ function isSafeRelativeDeletionTarget(target: string, cwd: string, allowCurrentDirectory: boolean): boolean {
328
+ if (!target) return false;
329
+ if (target.startsWith("/") || target.startsWith("~") || target.startsWith("$")) return false;
330
+ if (/^[A-Za-z]:[\\/]/.test(target)) return false;
331
+ if (PATH_GLOB_CHARACTERS.test(target)) return false;
332
+
333
+ const normalizedSegments = target.replace(/^\.\/+/, "").split(/[\\/]/);
334
+ if (!allowCurrentDirectory && normalizedSegments.length === 1 && normalizedSegments[0] === "") return false;
335
+ if (normalizedSegments.some((segment) => segment === ".." || segment === ".git")) return false;
336
+
337
+ const projectRoot = resolve(cwd);
338
+ const resolvedTarget = resolve(projectRoot, target);
339
+ const relativeTarget = relative(projectRoot, resolvedTarget);
340
+ return (
341
+ Boolean(relativeTarget) &&
342
+ relativeTarget !== ".." &&
343
+ !relativeTarget.startsWith(`..${sep}`) &&
344
+ !relativeTarget.startsWith(sep)
345
+ );
346
+ }
347
+
348
+ function isScopedRmCommand(command: string, cwd: string): boolean {
349
+ if (SHELL_CONTROL_CHARACTERS.test(command) || PATH_GLOB_CHARACTERS.test(command)) return false;
350
+ const tokens = command.trim().split(/[ \t]+/).filter(Boolean);
351
+ if (tokens.shift()?.toLowerCase() !== "rm") return false;
352
+
353
+ let optionsEnded = false;
354
+ const targets: string[] = [];
355
+ for (const token of tokens) {
356
+ if (!optionsEnded && token === "--") {
357
+ optionsEnded = true;
358
+ continue;
359
+ }
360
+ if (!optionsEnded && token.startsWith("-")) continue;
361
+ // An option after the first target means the command is not a simple deletion.
362
+ if (token.startsWith("-")) return false;
363
+ targets.push(token);
364
+ }
365
+
366
+ return targets.length > 0 && targets.every((target) => isSafeRelativeDeletionTarget(target, cwd, false));
367
+ }
368
+
369
+ function isScopedFindDeleteCommand(command: string, cwd: string): boolean {
370
+ if (SHELL_CONTROL_CHARACTERS.test(command) || PATH_GLOB_CHARACTERS.test(command)) return false;
371
+ const tokens = command.trim().split(/[ \t]+/).filter(Boolean);
372
+ if (tokens.shift()?.toLowerCase() !== "find") return false;
373
+ if (tokens.includes("-exec") || tokens.includes("-execdir")) return false;
374
+ if (!tokens.includes("-delete")) return false;
375
+
376
+ const expressionStart = tokens.findIndex(
377
+ (token) => token.startsWith("-") || token === "!" || token === "(" || token === ")",
378
+ );
379
+ if (expressionStart <= 0) return false;
380
+
381
+ const roots = tokens.slice(0, expressionStart);
382
+ const hasNarrowingPredicate = tokens.some((token) => FIND_NARROWING_PREDICATES.has(token));
383
+ return roots.length > 0 && roots.every((root) => isSafeRelativeDeletionTarget(root, cwd, hasNarrowingPredicate));
384
+ }
385
+
386
+ /** `rm -rf build` / `find build -type f -delete` under the working directory. */
387
+ export function isScopedLocalDeletionCommand(command: string, cwd: string): boolean {
388
+ return isScopedRmCommand(command, cwd) || isScopedFindDeleteCommand(command, cwd);
389
+ }
390
+
391
+ /** Read-only built-ins only: the commands the gate can vouch for on its own. */
392
+ export function isReadOnlyCommand(command: string): boolean {
393
+ return matchesAnyCommandPattern(command, SAFE_COMMANDS, false) !== undefined;
394
+ }
395
+
396
+ /**
397
+ * A chain of read-only commands, such as `cd src && ls -la && git log`.
398
+ *
399
+ * Agents chain commands constantly, and a single `&&` would otherwise take an
400
+ * otherwise harmless line out of the fast path and into a judgment round trip.
401
+ * Every segment must be read-only on its own; `curl โ€ฆ | sh` splits into `curl โ€ฆ`
402
+ * (not on the list) and `sh` (not on the list), so it is still judged, and a
403
+ * segment containing a redirection fails the matcher anyway.
404
+ */
405
+ export function isReadOnlyCommandChain(command: string): boolean {
406
+ const segments = command
407
+ .split(/&&|\|\||;|\||\n/)
408
+ .map((segment) => segment.trim())
409
+ .filter((segment) => segment.length > 0);
410
+ if (segments.length === 0) return false;
411
+ return segments.every((segment) => isReadOnlyCommand(segment));
412
+ }
413
+
414
+ /**
415
+ * Patterns the user declared safe.
416
+ *
417
+ * Kept separate from the built-in list because the two have different standing: a
418
+ * user declaration outranks a dangerous-pattern match, while the built-in read-only
419
+ * list does not (`grep secret ~/.ssh/id_ed25519` looks like reading and must still be
420
+ * judged).
421
+ */
422
+ export function isUserDeclaredSafeCommand(command: string, safeCommands: readonly string[]): boolean {
423
+ return matchesAnyCommandPattern(command, safeCommands, false) !== undefined;
424
+ }
425
+
426
+ /** Either list. Convenience for callers that do not need the distinction. */
427
+ export function isSafeCommand(command: string, extraPatterns: readonly string[] = []): boolean {
428
+ return isReadOnlyCommand(command) || isUserDeclaredSafeCommand(command, extraPatterns);
429
+ }
430
+
431
+ /**
432
+ * Names of the dangerous patterns a bash command matches, i.e. the reasons this
433
+ * call has to be judged instead of running straight through.
434
+ *
435
+ * Returns an empty array when the command is safe to run as far as this layer can
436
+ * tell. That is the fast path: the default is to stay out of the way.
437
+ */
438
+ export function dangerousReasons(command: string, cwd?: string): string[] {
439
+ const reasons = unique(DANGEROUS_PATTERNS.filter(({ pattern }) => pattern.test(command)).map(({ name }) => name));
440
+ if (cwd && isScopedLocalDeletionCommand(command, cwd)) {
441
+ return reasons.filter((reason) => !LOCAL_DELETION_REASONS.has(reason));
442
+ }
443
+ return reasons;
444
+ }
445
+
446
+ /** Directories that hold credentials, agent configuration, or CI definitions. */
447
+ export const PROTECTED_DIRECTORY_SEGMENTS: readonly string[] = [
448
+ ".git",
449
+ ".ssh",
450
+ ".aws",
451
+ ".gnupg",
452
+ ".husky",
453
+ ".pi",
454
+ ".claude",
455
+ ".codex",
456
+ ];
457
+
458
+ /** Path fragments that are security-relevant even outside the segments above. */
459
+ const PROTECTED_PATH_FRAGMENTS: readonly string[] = ["/.github/workflows/", "/.config/gh/"];
460
+
461
+ const PROTECTED_FILE_PATTERNS: readonly RegExp[] = [
462
+ // `.env.example` and friends are templates that belong in the repository, so they are
463
+ // not treated as credential stores. The real files still are.
464
+ /^\.env(?!\.(?:example|sample|template|dist)$)(?:\..+)?$/i,
465
+ /^\.npmrc$/i,
466
+ /^\.netrc$/i,
467
+ /^\.mcp\.json$/i,
468
+ /^credentials(?:\.json)?$/i,
469
+ /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$/i,
470
+ /\.(?:pem|key|p12|pfx)$/i,
471
+ /^\.(?:zshrc|bashrc|bash_profile|profile|zprofile|zlogin)$/i,
472
+ // Agent instruction files are a prompt-injection surface: a write there can
473
+ // change what the agent believes it has been told.
474
+ /^AGENTS\.md$/i,
475
+ /^CLAUDE\.md$/i,
476
+ ];
477
+
478
+ function normalizeForMatching(absolutePath: string): string {
479
+ return absolutePath.replace(/\\/g, "/");
480
+ }
481
+
482
+ /**
483
+ * Classify a path against the protected locations.
484
+ *
485
+ * `extraProtectedPaths` comes from settings. An entry containing `/` is matched as a
486
+ * path fragment; anything else is matched against the file name. Matching is
487
+ * case-insensitive on the normalized path.
488
+ */
489
+ export function protectedPathReason(
490
+ absolutePath: string,
491
+ extraProtectedPaths: readonly string[] = [],
492
+ ): string | undefined {
493
+ // Matching is case-insensitive, but the reported name keeps the original case:
494
+ // the message is read by a person looking at their own path.
495
+ const normalized = normalizeForMatching(absolutePath);
496
+ const lowered = normalized.toLowerCase();
497
+ const segments = normalized.split("/").filter(Boolean);
498
+ const loweredSegments = lowered.split("/").filter(Boolean);
499
+ const baseName = segments[segments.length - 1] ?? "";
500
+ const loweredBaseName = loweredSegments[loweredSegments.length - 1] ?? "";
501
+
502
+ const extraEntry = extraProtectedPaths
503
+ .map((entry) => entry.trim())
504
+ .filter((entry) => entry.length > 0)
505
+ .find((entry) => {
506
+ const loweredEntry = entry.toLowerCase();
507
+ return loweredEntry.includes("/") ? lowered.includes(loweredEntry) : loweredBaseName === loweredEntry;
508
+ });
509
+ if (extraEntry) return `configured protected path \`${extraEntry}\``;
510
+
511
+ const segment = loweredSegments.find((part) => PROTECTED_DIRECTORY_SEGMENTS.includes(part));
512
+ if (segment) return `protected directory \`${segment}\``;
513
+
514
+ const fragment = PROTECTED_PATH_FRAGMENTS.find((part) => lowered.includes(part));
515
+ if (fragment) return `protected path \`${fragment}\``;
516
+
517
+ const filePattern = PROTECTED_FILE_PATTERNS.find((pattern) => pattern.test(baseName));
518
+ if (filePattern) return `protected file \`${baseName}\``;
519
+
520
+ return undefined;
521
+ }
522
+
523
+ export interface WriteTarget {
524
+ readonly absolute: string;
525
+ readonly relativeToCwd: string | undefined;
526
+ readonly outsideCwd: boolean;
527
+ readonly protectedReason: string | undefined;
528
+ }
529
+
530
+ /**
531
+ * Classify a write/edit target lexically.
532
+ *
533
+ * A symlink inside the working directory can still point outside it; resolving
534
+ * that needs a filesystem call and belongs to the Jev layer's state building.
535
+ */
536
+ export function classifyWriteTarget(
537
+ inputPath: string,
538
+ cwd: string,
539
+ extraProtectedPaths: readonly string[] = [],
540
+ ): WriteTarget {
541
+ const projectRoot = resolve(cwd);
542
+ const absolute = resolve(projectRoot, inputPath);
543
+ const relativeToCwd = relative(projectRoot, absolute);
544
+ const outsideCwd = isAbsolute(relativeToCwd)
545
+ ? true
546
+ : relativeToCwd === ".." || relativeToCwd.startsWith(`..${sep}`) || relativeToCwd === "";
547
+
548
+ return {
549
+ absolute,
550
+ relativeToCwd: outsideCwd ? undefined : relativeToCwd,
551
+ outsideCwd,
552
+ protectedReason: protectedPathReason(absolute, extraProtectedPaths),
553
+ };
554
+ }
555
+
556
+ export function unique<T>(values: readonly T[]): T[] {
557
+ return [...new Set(values)];
558
+ }
@@ -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
+ }