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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/SECURITY.md +32 -0
- package/docs/calibration.md +134 -0
- package/docs/design.md +119 -0
- package/docs/security.md +122 -0
- package/index.ts +1 -0
- package/package.json +67 -0
- package/src/call.ts +180 -0
- package/src/decide.ts +81 -0
- package/src/extension.ts +705 -0
- package/src/intent.ts +68 -0
- package/src/jev/availability.ts +51 -0
- package/src/jev/criteria.ts +19 -0
- package/src/jev/decide.ts +187 -0
- package/src/jev/engine.ts +163 -0
- package/src/jev/index.ts +20 -0
- package/src/jev/questions.ts +228 -0
- package/src/jev/response.ts +64 -0
- package/src/jev/state.ts +20 -0
- package/src/jev/transport.ts +117 -0
- package/src/jev/types.ts +46 -0
- package/src/policy.ts +464 -0
- package/src/records.ts +118 -0
- package/src/settings.ts +274 -0
- package/src/ui.ts +125 -0
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
|
+
}
|